diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx index 52da0ef7f74..31110c79c1c 100644 --- a/apps/docs/content/docs/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/knowledgebase/connectors.mdx @@ -8,7 +8,7 @@ import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' import { FAQ } from '@/components/ui/faq' -For workspace Search with each person's source permissions, use the [Search connector guides](/search). This page covers connectors inside general knowledge bases. +For organization Search with each person's source permissions, use the [Search connector guides](/search). This page covers connectors inside general knowledge bases. Connectors continuously sync documents from external services into your knowledge base, so you never have to upload files manually. New content is added, changed content is re-processed, and deleted content is removed — all automatically. diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index d6f5b3f806b..5d63dbe45b7 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -56,8 +56,6 @@ Each teammate completes this identity connection. An existing authorized account -For workspace Search, start from **Search → Add source**. Available methods depend on the enabled features. - ## Connect member accounts After an admin configures Confluence, open **Integrations** and select **Connect** beside Confluence. Authorize your account; you do not enter the site's domain or choose its spaces again. diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx index 516e03cf2a6..18b737e4efc 100644 --- a/apps/docs/content/docs/search/github.mdx +++ b/apps/docs/content/docs/search/github.mdx @@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image' GitHub Search indexes repository text files and supported PDF and Office documents on `github.com`. An organization admin can install the GitHub App once and use it to index selected repositories. Each person connects their own GitHub account once to search the repositories they can access. Installing the App does not connect teammates or give them the installer's permissions. -Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. Installation indexing is available for organization Search. For workspace Search, use **Search → Add source** with member accounts or a dedicated user account. +Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. Installation indexing is available for organization Search. ## Before you start diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx index 55848547f6c..b82a0664b76 100644 --- a/apps/docs/content/docs/search/gitlab.mdx +++ b/apps/docs/content/docs/search/gitlab.mdx @@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image' Connect one GitLab project per source. Choose an **Administrator token** to refresh permissions from GitLab, or a **Non-admin token** with two CSV files to manage access in Sim. Teammates do not connect individual GitLab accounts. -Organization admins set up sources in **Settings → Sources**. For workspace Search, use **Search → Add source**. The same token paths are available when adding a GitLab connector to a regular knowledge base. +Organization admins set up sources in **Settings → Sources**. The same token paths are available when adding a GitLab connector to a regular knowledge base. ## Choose a token path diff --git a/apps/docs/content/docs/search/gmail.mdx b/apps/docs/content/docs/search/gmail.mdx index 8945d725c4b..c030c526062 100644 --- a/apps/docs/content/docs/search/gmail.mdx +++ b/apps/docs/content/docs/search/gmail.mdx @@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image' Search email threads from your Gmail account. Members can connect their own accounts, or an administrator can index Google Workspace mailboxes with a service account. In either case, each mailbox stays private to its owner. -Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead. +Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. ## Choose your setup diff --git a/apps/docs/content/docs/search/google-calendar.mdx b/apps/docs/content/docs/search/google-calendar.mdx index 246252a3a6a..2f302719ebc 100644 --- a/apps/docs/content/docs/search/google-calendar.mdx +++ b/apps/docs/content/docs/search/google-calendar.mdx @@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image' Search meetings and event details available to your Google account. Members can connect personal accounts, or a Google Workspace administrator can configure a central service-account crawl. Both paths keep each person's own view of events separate. -Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead. +Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. ## Choose your setup diff --git a/apps/docs/content/docs/search/google-drive.mdx b/apps/docs/content/docs/search/google-drive.mdx index c1e01e57347..0565a5289f7 100644 --- a/apps/docs/content/docs/search/google-drive.mdx +++ b/apps/docs/content/docs/search/google-drive.mdx @@ -9,7 +9,7 @@ import { Image } from '@/components/ui/image' Search Google Docs, Sheets, Slides, and supported files in Drive. Members can connect their own accounts after an admin allows Google Drive, or an admin can configure a central service-account crawl. -Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead. +Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. ## Choose your setup diff --git a/apps/docs/content/docs/search/index.mdx b/apps/docs/content/docs/search/index.mdx index 063d27d9243..f2e080397ab 100644 --- a/apps/docs/content/docs/search/index.mdx +++ b/apps/docs/content/docs/search/index.mdx @@ -120,10 +120,6 @@ To deactivate an entire integration, open it from **Settings → Sources**, sele To use these sources from Claude, Codex, Claude Code, Cursor, or another compatible app, open **Settings → Search MCP**. Each person signs in with their own Sim account. The server provides `search`, `read_document`, and `chat`; `chat` starts a new private Sim conversation. See [Search MCP](/search/mcp) for app setup, permissions, and limits. -## Existing workspace Search - -Workspace Search remains separate. Workspace admins add sources through **Search → Add source**; the member-account action is **Create & Invite**. Teammates need workspace access and connect from its source list. Organization Search does not automatically include workspace sources or grant access to workspace content. - ## Check that it works 1. Let the first sync finish, then search for a distinctive phrase in a document you can open in the source. diff --git a/apps/docs/content/docs/search/jira.mdx b/apps/docs/content/docs/search/jira.mdx index bb41c9f9581..9faacf30110 100644 --- a/apps/docs/content/docs/search/jira.mdx +++ b/apps/docs/content/docs/search/jira.mdx @@ -71,8 +71,6 @@ Each teammate follows this step. Sim reuses an existing authorized account where -For workspace Search, start from **Search → Add source**; its source-creation button is **Create & Invite**. Search manages the sync schedule and hides item limits. - ## Connect before a source exists After an admin approves Jira, a teammate can select **Connect** on the Jira row in **Integrations**. To add another site or project selection later, open the Jira row's actions menu (**…**) and select **Add projects**: diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx index bd158b43996..26544966a73 100644 --- a/apps/docs/content/docs/search/slack.mdx +++ b/apps/docs/content/docs/search/slack.mdx @@ -134,8 +134,6 @@ Compare **OAuth & Permissions → Scopes → User Token Scopes** with the table Existing workflow account pools retain their configured permissions; preserve those scopes when updating the shared app. -For existing **workspace** Search, use **Search → Add source → Slack**. That flow uses the custom-bot wizard and **Connected accounts → Access → Search documents**, which requests read-only channel, DM, and identity scopes. Its bot installation is separate from the organization setup described here. - See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manifest/) and [user token access model](https://docs.slack.dev/authentication/tokens/). ## Troubleshooting diff --git a/apps/sim/app/api/knowledge/member-connectors/route.ts b/apps/sim/app/api/knowledge/member-connectors/route.ts index 4467c3ec35e..3b7d453ea6e 100644 --- a/apps/sim/app/api/knowledge/member-connectors/route.ts +++ b/apps/sim/app/api/knowledge/member-connectors/route.ts @@ -12,7 +12,9 @@ export const GET = defineInternalJsonRoute({ contract: listWorkspaceMemberConnectorsContract, auth: internalSessionAuth, operation: knowledgeOperations.listWorkspaceMemberConnectors, - rateLimit: internalRateLimits.none({ reason: 'One small read per visit to the Search tab' }), + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal connector listing behavior', + }), errorPolicy: internalKnowledgeErrorPolicies.connectors, mapInput: ({ query }) => ({ workspaceId: query.workspaceId }), useCase: listWorkspaceMemberConnectors, diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx index 2ef014a73a6..39bfb5f40e5 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx @@ -225,9 +225,9 @@ describe('focused Search enrollment', () => { expect(document.querySelector('form')).toBeNull() expect( Array.from(document.querySelectorAll('a')) - .find((link) => link.textContent === 'Return to Search') + .find((link) => link.textContent === 'Open knowledge bases') ?.getAttribute('href') - ).toBe('/workspace/canonical-workspace/search') + ).toBe('/workspace/canonical-workspace/knowledge') expect(document.body.textContent).not.toContain('Setup guide') expect(mocks.read).toHaveBeenCalledWith({ principal, input: { optionId: 'site-two' } }) }) @@ -243,7 +243,7 @@ describe('focused Search enrollment', () => { expect(document.body.textContent).toContain('Ask an admin') expect(oauthLinks()).toHaveLength(0) expect(document.querySelector('form')).toBeNull() - expect(document.body.textContent).toContain('Return to Search') + expect(document.body.textContent).toContain('Open knowledge bases') } ) @@ -255,7 +255,7 @@ describe('focused Search enrollment', () => { expect(document.body.textContent).toContain('Connection unavailable') expect(document.body.textContent).toContain('Ask an admin') expect(document.querySelector('a')?.getAttribute('href')).toBe( - '/workspace/canonical-workspace/search' + '/workspace/canonical-workspace/knowledge' ) }) @@ -274,7 +274,7 @@ describe('focused Search enrollment', () => { expect(document.querySelector('h1')?.textContent).toBe('Confluence connected') expect(oauthLinks()).toHaveLength(0) expect(document.querySelector('form')).toBeNull() - expect(document.body.textContent).toContain('Return to Search') + expect(document.body.textContent).toContain('Open knowledge bases') }) it('does not treat a success query marker as a connected account', async () => { diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index c819e8c122a..4550345da53 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -184,8 +184,12 @@ export default async function CredentialGroupEnrollmentPage({ const canReturnToSearch = returnToSearch && ('canSearch' in enrollmentResult ? enrollmentResult.canSearch : !principal.organizationId) - const returnHref = canReturnToSearch ? searchReturnPath(principal) : APP_ENTRY_PATH - const returnLabel = canReturnToSearch ? 'Return to Search' : 'Open Sim' + const returnHref = canReturnToSearch ? sourceReturnPath(principal) : APP_ENTRY_PATH + const returnLabel = canReturnToSearch + ? principal.organizationId + ? 'Return to Search' + : 'Open knowledge bases' + : 'Open Sim' if (!enrollment) return @@ -328,9 +332,9 @@ export default async function CredentialGroupEnrollmentPage({ ) } -function searchReturnPath(owner: ResourceOwner): string { +function sourceReturnPath(owner: ResourceOwner): string { const scope = resourceScopeFromOwner(owner) return scope.kind === 'workspace' - ? `/workspace/${encodeURIComponent(scope.workspaceId)}/search` + ? `/workspace/${encodeURIComponent(scope.workspaceId)}/knowledge` : organizationRoutes(scope.organizationId).search } diff --git a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx index b29d1b481cc..9db4e9751da 100644 --- a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx @@ -11,30 +11,9 @@ vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-show IntegrationTile: () => null, })) -import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors' import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' -import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' const accounts = [{ credentialId: 'my-gmail', displayName: 'me@example.test' }] -const source: SearchSourceSummary = { - knowledgeBaseId: 'kb', - connectorId: 'gmail', - connectorType: 'gmail', - sourceDescription: '', - accessMode: 'members', - availability: 'available', - enabled: true, - isSyncing: true, - lastSyncAt: null, - hasSyncError: false, - viewerDocumentCount: 0, - viewerFailedDocumentCount: 0, - viewerEmailVerified: true, - connectionRequired: true, - viewerMembership: 'connected', - viewerAccounts: accounts, -} - describe('personal integration disconnect', () => { let root: Root let container: HTMLDivElement @@ -56,25 +35,10 @@ describe('personal integration disconnect', () => { container.remove() vi.unstubAllGlobals() }) - async function render(overrides: Partial = {}) { + async function render() { await act(async () => root.render( - - } - /> + ) ) } @@ -96,15 +60,8 @@ describe('personal integration disconnect', () => { )! } - it.each([ - ['indexing', {}], - ['failed', { hasSyncError: true }], - ['paused', { enabled: false }], - ['deactivated', { approved: false }], - ['reconnect', { viewerMembership: 'needs_reauth' }], - ['unavailable', { availability: 'unavailable', viewerMembership: null }], - ] as const)('allows disconnect while %s without requiring admin access', async (_, overrides) => { - await render(overrides) + it('requires confirmation before disconnecting an account', async () => { + await render() await openDisconnect() expect(document.body.textContent).toContain( 'Disconnect me@example.test from all Gmail connections in this organization.' diff --git a/apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx b/apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx index 70c9061b950..a80ca7184f1 100644 --- a/apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/member-integration-row.tsx @@ -3,9 +3,9 @@ import { Chip, ChipLink } from '@sim/emcn' import { organizationRoutes } from '@/lib/navigation/paths' import { connectorDisplayName } from '@/lib/sim-search/connectors' +import { getSearchSourceStatus } from '@/lib/sim-search/source-status' import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' -import { getSearchSourceStatus } from '@/app/workspace/[workspaceId]/search/components/search-source-row-status' import type { RowAction } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx index bb3eab8e511..416f7f7746e 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx @@ -43,7 +43,7 @@ vi.mock('@/hooks/queries/search-integrations', () => ({ vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: mocks.availability, })) -vi.mock('@/app/workspace/[workspaceId]/search/components/search-source-setup', () => ({ +vi.mock('@/app/o/[organizationId]/settings/components/integrations/search-source-setup', () => ({ SearchSourceSetup: () => null, })) vi.mock('@/app/o/[organizationId]/settings/components/integrations/slack-account-setup', () => ({ diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx index 359388941bd..4215fd58bab 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx @@ -6,16 +6,13 @@ import { useQueryStates } from 'nuqs' import { SettingsPanel } from '@/components/settings/settings-panel' import { organizationRoutes } from '@/lib/navigation/paths' import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' +import { searchSetupAccessParam, searchSetupParam } from '@/lib/sim-search/search-params' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { AddOrganizationSourceModal } from '@/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal' import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' +import { SearchSourceSetup } from '@/app/o/[organizationId]/settings/components/integrations/search-source-setup' import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' -import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup' -import { - searchSetupAccessParam, - searchSetupParam, -} from '@/app/workspace/[workspaceId]/search/search-params' import { SettingsEmptyState, SettingsQueryErrorState, diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-pagination.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-pagination.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/search/components/search-source-pagination.tsx rename to apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-pagination.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx similarity index 79% rename from apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx rename to apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx index 3266b86d157..267977e0ead 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx @@ -23,7 +23,6 @@ const mocks = vi.hoisted(() => ({ userId: 'user-1', urlUpdate: vi.fn(), oauthReturn: vi.fn(), - sourceStatus: vi.fn(), features: { knowledgeMemberAccess: true, knowledgeSourceMirroredAccess: true }, create: vi.fn(), update: vi.fn(), @@ -105,16 +104,10 @@ vi.mock('@/hooks/use-permission-config', () => ({ refetchIntegrationAvailability: mocks.refetchAvailability, }), })) -vi.mock('@/app/workspace/[workspaceId]/search/components/search-source-status', () => ({ - SearchSourceStatus: (props: { knowledgeBaseId: string; connectorType: string }) => { - mocks.sourceStatus(props) - return
Source sync status
- }, -})) vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: mocks.replace, push: mocks.push }), useParams: () => ({ workspaceId: 'workspace-1' }), - usePathname: () => '/workspace/workspace-1/search', + usePathname: () => '/o/org-1/settings/integrations', })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ useWorkspaceHostContext: () => ({ ownerBilling: {}, features: mocks.features }), @@ -209,10 +202,10 @@ vi.mock('@/hooks/use-credential-refresh-triggers', () => ({ import type { ConnectorData } from '@/lib/api/contracts/knowledge/connectors' import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { SearchSourceSetup } from '@/app/o/[organizationId]/settings/components/integrations/search-source-setup' import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal' import { AddConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal' import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal' -import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup' import { useConnectorSetupStore } from '@/stores/connector-setup/store' let root: Root | null = null @@ -382,17 +375,6 @@ afterEach(async () => { vi.restoreAllMocks() }) -function setup() { - return ( - - ) -} - function organizationSetup() { return ( { + it.each([false, true])('does not load setup while closed (admin=%s)', async (canAdmin) => { + mocks.canAdmin = canAdmin + await render(organizationSetup()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(mocks.basesQuery).toHaveBeenLastCalledWith('org-1', { enabled: false }) + expect(mocks.prepare).not.toHaveBeenCalled() + }) + + it.each(['?addConnector=gitlab', '?manage-source=source-one'])( + 'blocks reader setup at %s', + async (query) => { + mocks.canAdmin = false + await render(organizationSetup(), query) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(mocks.basesQuery).toHaveBeenLastCalledWith('org-1', { enabled: false }) + expect(mocks.prepare).not.toHaveBeenCalled() + expect(mocks.replace).not.toHaveBeenCalled() + } + ) + it.each([ { type: 'google_drive', mode: 'admin', name: 'Connect Google Drive service account' }, { type: 'gmail', mode: 'admin', name: 'Connect Gmail service account' }, @@ -477,15 +479,6 @@ describe('organization setup entry points', () => { } ) - it('keeps the broad provider picker without automatically preparing an index', async () => { - mocks.bases = [] - await render(setup(), '?addConnector=') - expect(document.body.textContent).toContain('Find a source') - expect(document.body.textContent).toContain('Google Drive') - expect(document.body.textContent).toContain('Confluence') - expect(mocks.prepare).not.toHaveBeenCalled() - }) - it('retries failed preparation only when requested', async () => { mocks.bases = [] await render(organizationSetup(), '?addConnector=google_drive') @@ -568,7 +561,7 @@ describe('organization setup entry points', () => { await render(organizationSetup(), `?addConnector=${type}`) expect(mocks.replace).toHaveBeenCalledWith('/o/org-1/integrations') expect(mocks.basesQuery).toHaveBeenLastCalledWith('org-1', { enabled: false }) - expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined) + expect(mocks.connectorsQuery).not.toHaveBeenCalled() expect(document.querySelector('[role="dialog"]')).toBeNull() expect(mocks.prepare).not.toHaveBeenCalled() } @@ -760,181 +753,11 @@ describe('Search source setup with real connector dialogs', () => { ) if (source === 'source-one') expect(mocks.replace).toHaveBeenCalledWith(destination) else expect(mocks.replace).not.toHaveBeenCalled() - expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined) - expect(document.querySelector('[role="dialog"]')).toBeNull() - } - ) - - it.each([false, true])( - 'does not fetch admin data while closed for canAdmin=%s', - async (canAdmin) => { - mocks.canAdmin = canAdmin - await render(setup()) - expect(document.querySelector('[role="dialog"]')).toBeNull() - expect(mocks.basesQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false }) - expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined) - } - ) - - it.each(['?addConnector=gitlab', '?manage-source=site-one', '?manage-source=confluence'])( - 'does not expose the catalog or admin queries to a reader opening %s', - async (searchParams) => { - mocks.canAdmin = false - await render(setup(), searchParams) + expect(mocks.connectorsQuery).not.toHaveBeenCalled() expect(document.querySelector('[role="dialog"]')).toBeNull() - expect(mocks.basesQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false }) - expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined) - expect(mocks.prepare).not.toHaveBeenCalled() - } - ) - - it('closes source management and disables admin queries when the viewer loses admin access', async () => { - mocks.connectors = [ - { id: 'site-one', connectorType: 'confluence', accessMode: 'admin', status: 'active' }, - ] - await render(setup(), '?manage-source=site-one') - expect(document.querySelector('[role="dialog"]')).not.toBeNull() - mocks.canAdmin = false - mocks.sourceStatus.mockClear() - await render(setup(), '?manage-source=site-one') - expect(document.querySelector('[role="dialog"]')).toBeNull() - expect(mocks.basesQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false }) - expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined) - expect(mocks.sourceStatus).not.toHaveBeenCalled() - }) - - it('lists each eligible provider once and filters the add-source catalog', async () => { - await render(setup(), '?addConnector=') - expect( - Array.from(document.querySelectorAll('button')).filter( - (node) => node.textContent === 'Set up' - ) - ).toHaveLength(8) - for (const name of [ - 'Confluence', - 'GitHub', - 'GitLab', - 'Gmail', - 'Google Calendar', - 'Google Drive', - 'Jira', - 'Slack', - ]) { - expect(document.body.textContent).toContain(name) - } - await fill('Find a source…', 'no-such-source') - expect(document.body.textContent).toContain('No matching sources.') - expect(document.body.textContent).not.toContain('Google Drive') - expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined) - }) - - it('waits for availability and offers a retry after it fails', async () => { - mocks.availabilityReady = false - mocks.availabilityLoading = true - await render(setup(), '?addConnector=') - expect(document.body.textContent).toContain('Loading sources…') - expect( - Array.from(document.querySelectorAll('button')).some((node) => node.textContent === 'Set up') - ).toBe(false) - mocks.availabilityLoading = false - mocks.availabilityError = new Error('Availability failed') - await render(setup(), '?addConnector=') - expect(document.body.textContent).toContain('Availability failed') - await click(button('Try again')) - expect(mocks.refetchAvailability).toHaveBeenCalledOnce() - }) - - it('does not offer GitHub App setup when only its workflow token integration is available', async () => { - mocks.unavailableProviders = ['github-repositories'] - await render(setup(), '?addConnector=') - expect( - Array.from(document.querySelectorAll('button')).filter( - (node) => node.textContent === 'Set up' - ) - ).toHaveLength(7) - expect(document.body.textContent).toContain('GitHub') - expect(document.body.textContent).toContain('Not available in this workspace') - }) - - it('preserves a setup draft while an availability refresh fails and recovers', async () => { - await render(setup(), '?addConnector=github') - await fill('owner/repo', 'acme/docs') - expect(button('Create & Invite').disabled).toBe(false) - mocks.availabilityReady = false - mocks.availabilityError = new Error('Availability refresh failed') - await render(setup(), '?addConnector=github') - expect(document.querySelector('input[placeholder="owner/repo"]')?.value).toBe( - 'acme/docs' - ) - expect(button('Create & Invite').disabled).toBe(true) - await click(button('Try again')) - expect(mocks.refetchAvailability).toHaveBeenCalledOnce() - mocks.availabilityReady = true - mocks.availabilityError = null - await render(setup(), '?addConnector=github') - expect(button('Create & Invite').disabled).toBe(false) - }) - - it.each(['gmail', 'jira', 'github', 'google_calendar'])( - 'retains %s member setup without workspace-wide access', - async (type) => { - await render(setup(), `?addConnector=${type}`) - if (type === 'gmail' || type === 'google_calendar') { - await click(button('Member accounts')) - } - expect(document.body.textContent).toContain('Member accounts') - expect( - Array.from(document.querySelectorAll('button')).some( - (node) => node.textContent === 'Workspace' - ) - ).toBe(false) - expect(document.body.textContent).not.toContain('Sync Frequency') - expect(document.body.textContent).not.toContain('Max Threads') - expect(document.body.textContent).not.toContain('Max Events') - expect(document.body.textContent).not.toContain('Max Files') - expect(document.body.textContent).not.toContain('Max Issues') - if (type === 'github') await fill('owner/repo', 'acme/docs') - if (type === 'jira') { - expect(button('Create & Invite').disabled).toBe(true) - await fill('yoursite.atlassian.net', 'acme.atlassian.net') - const modeToggle = document.querySelector( - 'button[aria-label="Switch Projects to manual input"]' - ) - expect(modeToggle).not.toBeNull() - await click(modeToggle!) - await fill('e.g. ENG, PROJ (comma-separated for multiple)', 'ENG') - } - if (type === 'gmail') { - expect(document.body.textContent).not.toContain('Browse with') - expect( - document.querySelector('input[placeholder="e.g. INBOX, Engineering (comma-separated)"]') - ).not.toBeNull() - expect(document.querySelector('button[aria-label="Switch Labels to selector"]')).toBeNull() - } - expect(button('Create & Invite').disabled).toBe(false) - await click(button('Create & Invite')) - expect(mocks.create).toHaveBeenCalledWith( - expect.objectContaining({ - knowledgeBaseId: 'kb-search', - connectorType: type, - accessMode: 'members', - }), - expect.any(Object) - ) } ) - it('prepares a canonical index instead of an ordinary base with the Search name', async () => { - mocks.bases = [{ id: 'ordinary-base', name: 'Sim Search', isSearchIndex: false }] - await render(setup(), '?addConnector=gitlab') - expect(mocks.prepare).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - connectorType: 'gitlab', - accessMode: 'admin', - }) - expect(mocks.connectorsQuery).toHaveBeenLastCalledWith(undefined) - }) - it('prepares organization connected-account indexing in members mode even when central access is available', async () => { mocks.bases = [] await render( @@ -952,169 +775,6 @@ describe('Search source setup with real connector dialogs', () => { accessMode: 'members', }) }) - - it('does not reuse mutation data after the current index has been removed', async () => { - mocks.prepareData = { knowledgeBaseId: 'kb-search' } - mocks.bases = [] - await render(setup(), '?addConnector=gitlab') - expect(mocks.prepare).toHaveBeenCalled() - expect(document.querySelector('input[placeholder="Enter your GitLab PAT"]')).toBeNull() - }) - - it.each(['bases', 'connectors'] as const)( - 'retries a failed %s discovery query', - async (query) => { - if (query === 'bases') mocks.basesError = new Error('Base discovery failed') - else mocks.connectorsError = new Error('Connector discovery failed') - await render(setup(), query === 'bases' ? '?addConnector=' : '?manage-source=gitlab-1') - expect(document.body.textContent).toContain('discovery failed') - await click(button('Try again')) - expect( - query === 'bases' ? mocks.refetchBases : mocks.refetchConnectors - ).toHaveBeenCalledOnce() - } - ) - - it('manages the exact source ID in a renamed canonical index', async () => { - mocks.bases = [ - { id: 'ordinary-base', name: 'Sim Search', isSearchIndex: false }, - { id: 'renamed-index', name: 'Company knowledge', isSearchIndex: true }, - ] - mocks.connectors = [ - { id: 'site-one', connectorType: 'confluence', accessMode: 'members', status: 'active' }, - { id: 'site-two', connectorType: 'confluence', accessMode: 'admin', status: 'active' }, - ] - await render(setup(), '?manage-source=site-two') - expect(mocks.connectorsQuery).toHaveBeenLastCalledWith('renamed-index') - expect(mocks.sourceStatus).toHaveBeenLastCalledWith( - expect.objectContaining({ - knowledgeBaseId: 'renamed-index', - connectorType: 'confluence', - connectors: [mocks.connectors[1]], - }) - ) - expect(mocks.prepare).not.toHaveBeenCalled() - }) - - it.each(['unknown-source', 'deleted-source'])( - 'shows unavailable for a missing connector ID: %s', - async (id) => { - mocks.connectors = [ - { - id: 'existing-source', - connectorType: 'confluence', - accessMode: 'members', - status: 'active', - }, - ] - await render(setup(), `?manage-source=${id}`) - expect(document.body.textContent).toContain('This source is no longer available.') - expect(document.body.textContent).not.toContain('Source sync status') - expect(mocks.sourceStatus).not.toHaveBeenCalled() - } - ) - - it('waits for connector discovery before declaring a management link unavailable', async () => { - mocks.connectorsPending = true - await render(setup(), '?manage-source=site-one') - expect(mocks.sourceStatus).toHaveBeenLastCalledWith( - expect.objectContaining({ isLoading: true, connectors: [] }) - ) - expect(document.body.textContent).not.toContain('This source is no longer available.') - mocks.connectorsPending = false - mocks.connectors = [ - { id: 'site-one', connectorType: 'confluence', accessMode: 'members', status: 'active' }, - ] - await render(setup(), '?manage-source=site-one') - expect(document.body.textContent).toContain('Source sync status') - expect(document.body.textContent).not.toContain('This source is no longer available.') - expect(mocks.sourceStatus).toHaveBeenLastCalledWith( - expect.objectContaining({ connectorType: 'confluence', connectors: mocks.connectors }) - ) - }) - - it('keeps a failed connector lookup retryable instead of treating it as deletion', async () => { - mocks.connectorsError = new Error('Source lookup failed') - await render(setup(), '?manage-source=site-one') - expect(document.body.textContent).toContain('Source lookup failed') - expect(document.body.textContent).not.toContain('This source is no longer available.') - expect(mocks.sourceStatus).not.toHaveBeenCalled() - await click(button('Try again')) - expect(mocks.refetchConnectors).toHaveBeenCalledOnce() - mocks.connectorsError = null - await render(setup(), '?manage-source=site-one') - expect(document.body.textContent).toContain('This source is no longer available.') - }) - - it('preserves existing provider-based management URLs', async () => { - mocks.connectors = [ - { id: 'site-one', connectorType: 'confluence', accessMode: 'members', status: 'active' }, - { id: 'site-two', connectorType: 'confluence', accessMode: 'admin', status: 'active' }, - ] - await render(setup(), '?manage-source=confluence') - expect(mocks.sourceStatus).toHaveBeenLastCalledWith( - expect.objectContaining({ connectors: mocks.connectors }) - ) - }) - - it('opens GitLab token tabs and submits the custom host and PAT with managed access', async () => { - await render(setup(), '?addConnector=gitlab') - expect(button('Administrator token')).toHaveAttribute('aria-checked', 'true') - expect(button('Non-admin token')).toHaveAttribute('aria-checked', 'false') - expect(document.body.textContent).not.toContain('Connection method') - expect(document.body.textContent).not.toContain('Member accounts') - expect(button('Connect & Sync')).toBeDisabled() - await fill('Enter your GitLab PAT', 'test-pat') - await fill('group/project or numeric ID', 'engineering/search') - expect(button('Connect & Sync')).toBeDisabled() - await fill('gitlab.example.com', 'gitlab.example.test') - expect(button('Connect & Sync')).toBeEnabled() - await click(button('Connect & Sync')) - expect(mocks.create).toHaveBeenCalledWith( - expect.objectContaining({ - knowledgeBaseId: 'kb-search', - connectorType: 'gitlab', - accessMode: 'admin', - apiKey: 'test-pat', - sourceConfig: expect.objectContaining({ - host: 'gitlab.example.test', - project: 'engineering/search', - }), - syncIntervalMinutes: 60, - }), - expect.any(Object) - ) - expect(mocks.create.mock.calls[0][0]).not.toHaveProperty('credentialId') - }) - - it('prepares Slack in members mode when mirrored access is disabled', async () => { - mocks.features.knowledgeSourceMirroredAccess = false - mocks.bases = [] - await render(setup(), '?addConnector=slack') - expect(mocks.prepare).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - connectorType: 'slack', - accessMode: 'members', - }) - }) - - it('blocks unavailable catalog providers and duplicate preparation while preserving retry feedback', async () => { - mocks.features.knowledgeMemberAccess = false - mocks.features.knowledgeSourceMirroredAccess = false - await render(setup(), '?addConnector=') - expect(document.body.textContent).toContain('Not available in this workspace') - expect( - Array.from(document.querySelectorAll('button')).some((node) => node.textContent === 'Set up') - ).toBe(false) - mocks.features.knowledgeSourceMirroredAccess = true - mocks.bases = [] - mocks.preparePending = true - mocks.prepareError = new Error('Source preparation failed') - await render(setup(), '?addConnector=') - await fill('Find a source…', 'gitlab') - expect(button('Set up')).toBeDisabled() - expect(document.body.textContent).toContain('Source preparation failed') - }) }) describe('member content credentials in real add and edit dialogs', () => { @@ -2061,27 +1721,6 @@ describe('canonical Search connector safety', () => { describe('resuming Search source setup', () => { const key = 'user-1:workspace-1:kb-search:slack:choose' - it('reopens the source from the URL even when the source filter hides its row', async () => { - await render(setup(), '?search=nothing-matches&addConnector=gitlab&credentialDraftId=draft-1') - expect(button('Administrator token')).toHaveAttribute('aria-checked', 'true') - expect(button('Non-admin token')).toHaveAttribute('aria-checked', 'false') - expect(document.body.textContent).not.toContain('Connection method') - expect(document.body.textContent).toContain('Add GitLab project') - expect(document.body.textContent).not.toContain('Sync Frequency') - expect(document.body.textContent).not.toContain('Sync automatically') - expect(mocks.prepare).not.toHaveBeenCalled() - }) - - it('keeps the picker open when changing sources and updates the configuration selection', async () => { - await render(setup(), '?addConnector=google_drive') - await click(button('Choose another source')) - expect(document.querySelector('[role="dialog"]')).not.toBeNull() - expect(document.body.textContent).toContain('Add source') - await fill('Find a source…', 'confluence') - await click(button('Set up')) - expect(document.body.textContent).toContain('Connect Confluence site') - }) - it('restores the source configuration and content account after an account-settings detour', async () => { const onCreated = vi.fn() const form = ( @@ -2105,7 +1744,7 @@ describe('resuming Search source setup', () => { const setup = Array.from(document.querySelectorAll('a')).find( (link) => link.textContent === 'Set up Slack' ) - expect(setup?.getAttribute('href')).toContain('search-setup=slack') + expect(setup?.getAttribute('href')).toBe('/workspace/workspace-1/settings/credential-groups') setup?.addEventListener('click', (event) => event.preventDefault()) await click(setup!) expect(useConnectorSetupStore.getState().getDraft(key)).toMatchObject({ @@ -2220,49 +1859,6 @@ describe('resuming Search source setup', () => { }) describe('Search setup guides', () => { - it('opens the source guide in a new tab without losing an administrator’s setup', async () => { - const open = vi.spyOn(window, 'open').mockReturnValue(null) - await render(setup(), '?addConnector=github') - await fill('owner/repo', 'acme/docs') - - await click(button('Setup guide')) - - expect(open).toHaveBeenCalledWith( - 'https://docs.sim.ai/search/github', - '_blank', - 'noopener,noreferrer' - ) - expect(document.querySelector('input[placeholder="owner/repo"]')?.value).toBe( - 'acme/docs' - ) - expect(mocks.create).not.toHaveBeenCalled() - expect(mocks.urlUpdate).not.toHaveBeenCalled() - await click(button('Create & Invite')) - expect(mocks.create).toHaveBeenCalledWith( - expect.objectContaining({ - connectorType: 'github', - sourceConfig: expect.objectContaining({ repository: 'acme/docs' }), - }), - expect.any(Object) - ) - }) - - it('offers the Slack guide before its custom app is configured', async () => { - const open = vi.spyOn(window, 'open').mockReturnValue(null) - mocks.credentialGroup = null - await render(setup(), '?addConnector=slack') - - await click(button('Setup guide')) - - expect(open).toHaveBeenCalledWith( - 'https://docs.sim.ai/search/slack', - '_blank', - 'noopener,noreferrer' - ) - expect(document.body.textContent).not.toContain('Create & Invite') - expect(mocks.create).not.toHaveBeenCalled() - }) - it('preserves a member’s required source fields while reading the guide', async () => { const open = vi.spyOn(window, 'open').mockReturnValue(null) const onClose = vi.fn() diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.tsx similarity index 80% rename from apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx rename to apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.tsx index 81f1c9fb562..1f2817c553d 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-setup.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.tsx @@ -18,18 +18,17 @@ import { useSession } from '@/lib/auth/auth-client' import { type ResourceScope, resourceScopeFields, - resourceScopeFromOwner, resourceScopeKey, } from '@/lib/core/resource-scope' import { organizationRoutes } from '@/lib/navigation/paths' import { getSearchConnectionLabels } from '@/lib/sim-search/connection-labels' import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' -import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { managedSourceParam, searchSetupAccessParam, searchSetupParam, -} from '@/app/workspace/[workspaceId]/search/search-params' +} from '@/lib/sim-search/search-params' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SettingsEmptyState, SettingsQueryErrorState, @@ -39,11 +38,7 @@ import { SettingsResourceRow, } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' -import { - useConnectorList, - usePrepareSearchSource, - useSearchIndex, -} from '@/hooks/queries/kb/connectors' +import { usePrepareSearchSource, useSearchIndex } from '@/hooks/queries/kb/connectors' import { usePermissionConfig } from '@/hooks/use-permission-config' const AddConnectorModal = dynamic( @@ -53,17 +48,8 @@ const AddConnectorModal = dynamic( ), { ssr: false } ) -const SearchSourceStatus = dynamic( - () => - import('@/app/workspace/[workspaceId]/search/components/search-source-status').then( - (module) => module.SearchSourceStatus - ), - { ssr: false } -) - interface SearchSourceSetupProps { - workspaceId?: string - scope?: ResourceScope + scope: Extract canAdmin: boolean memberAccessAvailable: boolean mirroredAccessAvailable: boolean @@ -71,13 +57,11 @@ interface SearchSourceSetupProps { /** Owns admin setup and existing source management, including bookmarked OAuth return URLs. */ export function SearchSourceSetup({ - workspaceId, - scope: explicitScope, + scope, canAdmin, memberAccessAvailable, mirroredAccessAvailable, }: SearchSourceSetupProps) { - const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId }) const { data: session } = useSession() const { integrationAvailability, @@ -109,16 +93,14 @@ export function SearchSourceSetup({ const { mutate: prepareSource, isPending: preparing } = prepare const selectedMeta = selectedType ? CONNECTOR_META_REGISTRY[selectedType] : undefined const redirectPersonalSetup = Boolean( - scope.kind === 'organization' && - canAdmin && + canAdmin && selectedMeta && setup['source-access'] !== 'members' && !selectedMeta.mirrorsSourceAcls && selectedType !== 'slack' ) - const redirectManagement = - scope.kind === 'organization' && canAdmin && managedSource !== null && selectedType === null - const organizationId = scope.kind === 'organization' ? scope.organizationId : undefined + const redirectManagement = canAdmin && managedSource !== null && selectedType === null + const { organizationId } = scope useEffect(() => { if (redirectPersonalSetup && organizationId) { router.replace(organizationRoutes(organizationId).integrations) @@ -138,46 +120,17 @@ export function SearchSourceSetup({ enabled: canAdmin && open && !redirectManagement && !redirectPersonalSetup, }) const knowledgeBaseId = index.data?.knowledgeBaseId ?? undefined - const connectors = useConnectorList( - canAdmin && managedSource && !redirectManagement ? knowledgeBaseId : undefined - ) const close = () => { if (prepare.isPending) return if (selectedType !== null) void setSelectedType(null) if (managedSource !== null) void setManagedSource(null) } - const failedQuery = index.isError - ? index - : managedSource && connectors.isError - ? connectors - : null - const managedConnectors = - connectors.data?.filter( - (connector) => connector.id === managedSource || connector.connectorType === managedSource - ) ?? [] - const managedType = - managedConnectors[0]?.connectorType ?? - (managedSource && CONNECTOR_META_REGISTRY[managedSource] ? managedSource : undefined) - const initialMode = (type: string) => { - if (scope.kind === 'organization') - return setup['source-access'] === 'members' || type === 'slack' - ? ('members' as const) - : ('admin' as const) - const meta = CONNECTOR_META_REGISTRY[type] - if ( - meta && - getConnectorAccessAvailability(meta, integrationAvailability, { - memberAccessAvailable, - mirroredAccessAvailable, - oauthServiceAvailability, - isIntegrationAvailabilityReady: - isIntegrationAvailabilityReady || integrationAvailability.size > 0, - }).admin - ) - return 'admin' as const - return 'members' as const - } + const failedQuery = index.isError ? index : null + const initialMode = (type: string) => + setup['source-access'] === 'members' || type === 'slack' + ? ('members' as const) + : ('admin' as const) const selectedAccessMode = selectedType ? initialMode(selectedType) : undefined const selectedAvailability = selectedMeta @@ -189,11 +142,9 @@ export function SearchSourceSetup({ }) : undefined const selectedAvailable = selectedAvailability - ? scope.kind === 'organization' - ? selectedAccessMode === 'admin' - ? selectedAvailability.admin - : selectedAvailability.members - : selectedAvailability.admin || selectedAvailability.members + ? selectedAccessMode === 'admin' + ? selectedAvailability.admin + : selectedAvailability.members : false const canPrepareSelected = canAdmin && @@ -241,11 +192,9 @@ export function SearchSourceSetup({ ) { if (selectedType && session?.user?.id) { const accessMode = initialMode(selectedType) - const setupMode = - scope.kind === 'organization' && - !(selectedMeta?.mirrorsSourceAcls && selectedMeta.auth.mode === 'oauth') - ? accessMode - : 'choose' + const setupMode = !(selectedMeta?.mirrorsSourceAcls && selectedMeta.auth.mode === 'oauth') + ? accessMode + : 'choose' return ( void setSelectedType(type !== null ? searchSetupParam.parser.parse(type) : null) } onCreated={async (_type, connector) => { - if (scope.kind !== 'organization') return await setSelectedType(null) router.push(organizationRoutes(scope.organizationId).searchSource(connector.id)) }} /> ) } - if (managedSource && (connectors.isPending || managedType)) { - return ( - void setManagedSource(null)} - /> - ) - } } if ( @@ -299,10 +235,7 @@ export function SearchSourceSetup({ const normalizedSearch = search.trim().toLowerCase() const visibleTypes = SEARCH_SOURCE_TYPES.filter( ([type, meta]) => - (scope.kind !== 'organization' || - setup['source-access'] === 'members' || - meta.mirrorsSourceAcls || - type === 'slack') && + (setup['source-access'] === 'members' || meta.mirrorsSourceAcls || type === 'slack') && `${meta.name} ${meta.description}`.toLowerCase().includes(normalizedSearch) ) @@ -400,11 +333,7 @@ export function SearchSourceSetup({ } ) const available = - scope.kind === 'organization' - ? setup['source-access'] === 'members' || type === 'slack' - ? members - : central - : central || members + setup['source-access'] === 'members' || type === 'slack' ? members : central return ( ( return

Provider account connections

}, })) -vi.mock('@/app/workspace/[workspaceId]/search/components/search-source-setup', () => ({ +vi.mock('@/app/o/[organizationId]/settings/components/integrations/search-source-setup', () => ({ SearchSourceSetup: (props: unknown) => { mocks.setup(props) return null diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx index ecf0c0f62b5..5e98e688040 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx @@ -11,18 +11,15 @@ import { SettingsPanel } from '@/components/settings/settings-panel' import { organizationRoutes } from '@/lib/navigation/paths' import { getSearchConnectionLabels } from '@/lib/sim-search/connection-labels' import { getConnectorAccessAvailability } from '@/lib/sim-search/connectors' +import { searchSetupAccessParam, searchSetupParam } from '@/lib/sim-search/search-params' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' import { connectedAccountsParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params' +import { SearchSourcePagination } from '@/app/o/[organizationId]/settings/components/integrations/search-source-pagination' +import { SearchSourceSetup } from '@/app/o/[organizationId]/settings/components/integrations/search-source-setup' import { OrganizationSlackAccountRemoval } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-removal' import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup' -import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination' -import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup' -import { - searchSetupAccessParam, - searchSetupParam, -} from '@/app/workspace/[workspaceId]/search/search-params' import { SettingsEmptyState, SettingsQueryErrorState, diff --git a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.test.tsx index 226c60c89b1..15fd822f500 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.test.tsx @@ -3,22 +3,13 @@ */ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockMemberAccessAvailable } = vi.hoisted(() => ({ - mockMemberAccessAvailable: vi.fn(() => true), -})) - -vi.mock('@/hooks/use-member-access', () => ({ - useMemberAccessAvailable: () => mockMemberAccessAvailable(), -})) - +import { afterEach, describe, expect, it } from 'vitest' import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header' let root: Root | null = null let container: HTMLDivElement | null = null -function mount(active: 'integrations' | 'skills' | 'search' = 'integrations') { +function mount(active: 'integrations' | 'skills' = 'integrations') { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) @@ -30,10 +21,6 @@ function tabs(): string[] { return Array.from(container?.querySelectorAll('a') ?? []).map((node) => node.textContent ?? '') } -beforeEach(() => { - mockMemberAccessAvailable.mockReturnValue(true) -}) - afterEach(() => { if (root) act(() => root?.unmount()) container?.remove() @@ -45,20 +32,14 @@ describe('IntegrationTabsHeader', () => { it('links every tab to its page in the routed workspace', () => { mount() - expect(tabs()).toEqual(['Integrations', 'Skills', 'Search']) + expect(tabs()).toEqual(['Integrations', 'Skills']) expect( Array.from(container?.querySelectorAll('a') ?? []).map((node) => node.getAttribute('href')) - ).toEqual([ - '/workspace/workspace-1/integrations', - '/workspace/workspace-1/skills', - '/workspace/workspace-1/search', - ]) + ).toEqual(['/workspace/workspace-1/integrations', '/workspace/workspace-1/skills']) }) - it('omits Search where per-member access is off, matching the page that 404s', () => { - mockMemberAccessAvailable.mockReturnValue(false) - mount() - + it('keeps the same workspace navigation on the Skills tab', () => { + mount('skills') expect(tabs()).toEqual(['Integrations', 'Skills']) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx index 248d7936669..11774143550 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/integration-tabs-header/integration-tabs-header.tsx @@ -3,38 +3,20 @@ import type { ReactNode } from 'react' import { ChipLink, cn } from '@sim/emcn' import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' -import { useMemberAccessAvailable } from '@/hooks/use-member-access' interface IntegrationTabsHeaderProps { - active: 'integrations' | 'skills' | 'search' + active: 'integrations' | 'skills' workspaceId: string /** Trailing actions for the owning page (e.g. skills' "Add skill"). */ rightSlot?: ReactNode } -/** - * Top-of-page tab header shared by the Integrations, Skills, and Search pages — - * three views of one surface, so each highlights itself and links to its siblings. - * - * Lives in the shared workspace components rather than under `integrations/` - * because every page owns it equally; its former home made Skills reach across - * into a sibling feature for its own chrome. - * - * Search appears only where per-member access is on, matching the page it links - * to, which 404s otherwise. A client component so the three pages and their - * Suspense fallbacks all read that one judgement from the workspace host - * context rather than each resolving it again on the server. - * - * The `gap-1` is explicit because chips carry no outer margin — the parent owns the - * space between them. - */ +/** Shared navigation for workspace integrations and skills. */ export function IntegrationTabsHeader({ active, workspaceId, rightSlot, }: IntegrationTabsHeaderProps) { - const memberAccessAvailable = useMemberAccessAvailable() - return (
@@ -43,11 +25,6 @@ export function IntegrationTabsHeader({ Skills - {memberAccessAvailable && ( - - Search - - )} {rightSlot &&
{rightSlot}
}
) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx index 85a3e336b60..6b9617c5d38 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.test.tsx @@ -4,9 +4,14 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ overview: vi.fn(), search: vi.fn(), retry: vi.fn() })) +const mocks = vi.hoisted(() => ({ + index: vi.fn(), + overview: vi.fn(), + search: vi.fn(), + retry: vi.fn(), +})) vi.mock('@/hooks/queries/kb/connectors', () => ({ - useSearchIndex: () => ({ data: { knowledgeBaseId: 'index' }, isPending: false }), + useSearchIndex: mocks.index, useSearchSourceOverview: mocks.overview, })) vi.mock('@/hooks/queries/kb/knowledge', () => ({ @@ -17,12 +22,14 @@ vi.mock( () => ({ SourceCard: ({ source }: { source: { title: string } }) => {source.title} }) ) +import type { ResourceScope } from '@/lib/core/resource-scope' import { KnowledgeSearchResults } from '@/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results' let root: Root let container: HTMLDivElement beforeEach(() => { vi.clearAllMocks() + mocks.index.mockReturnValue({ data: { knowledgeBaseId: 'index' }, isPending: false }) mocks.search.mockReturnValue({ data: { query: 'launch', results: [], retrieval: { status: 'complete', timedOutLegs: [] } }, isPending: false, @@ -38,11 +45,11 @@ afterEach(() => { act(() => root.unmount()) vi.unstubAllGlobals() }) -async function render() { +async function render(scope: ResourceScope = { kind: 'workspace', workspaceId: 'workspace' }) { await act(async () => root.render( - + ) ) @@ -120,3 +127,16 @@ describe('incomplete search coverage', () => { } ) }) + +describe('source setup navigation', () => { + it.each([ + [{ kind: 'workspace', workspaceId: 'workspace' }, '/workspace/workspace/knowledge'], + [{ kind: 'organization', organizationId: 'organization' }, '/o/organization/integrations'], + ] as const)('links empty results to the source page for %j', async (scope, href) => { + mocks.index.mockReturnValue({ data: { knowledgeBaseId: null }, isPending: false }) + mocks.overview.mockReturnValue({ data: undefined }) + await render(scope) + expect(container.textContent).toContain('No sources are set up yet.') + expect(container.querySelector('a')?.getAttribute('href')).toBe(href) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx index bb51a95f74c..3c92726ee09 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx @@ -179,7 +179,7 @@ export function KnowledgeSearchResults({ href={ scope.kind === 'organization' ? `/o/${scope.organizationId}/integrations` - : `/workspace/${scope.workspaceId}/search` + : `/workspace/${scope.workspaceId}/knowledge` } > View sources diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx index 4c848f9bc01..a8ca013792d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx @@ -41,7 +41,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), - usePathname: () => '/workspace/workspace-1/search', + usePathname: () => '/o/org-1/settings/integrations', })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ useWorkspaceHostContext: () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx index 70ca5117317..4ecbc389925 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx @@ -233,33 +233,40 @@ describe('connection method selection', () => { }) describe('Slack setup continuity', () => { - it('keeps the setup link and draft callback when the method selector is hidden', async () => { - const onNavigate = vi.fn() - await render({ - connectorConfig: slackConnectorMeta, - allowAdmin: false, - searchSetupSource: 'slack', - slackSetupOnly: true, - onSetupNavigate: onNavigate, - footer: , - }) - expect(container.querySelector('[role="radiogroup"]')).toBeNull() - expect(container.textContent).toContain('Slack app') - expect(container.textContent).not.toContain('Member accounts') - const link = container.querySelector('a') - const target = new URL(link?.getAttribute('href') ?? '', 'http://localhost') - expect(target.pathname).toBe('/workspace/workspace-1/settings/credential-groups') - expect(target.searchParams.get('search-setup')).toBe('slack') - expect(target.searchParams.get('credential-group-provider')).toBe('slack') - link?.addEventListener('click', (event) => event.preventDefault()) - await act(async () => link?.click()) - expect(onNavigate).toHaveBeenCalledOnce() - expect(container.textContent).toContain('Apply changes') - expect(mocks.accounts).toHaveBeenLastCalledWith({ - kind: 'workspace', - workspaceId: 'workspace-1', - }) - }) + it.each([ + { + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + href: '/workspace/workspace-1/settings/credential-groups', + }, + { + scope: { kind: 'organization', organizationId: 'org-1' }, + href: '/o/org-1/settings/integrations?search-setup=slack&connectedAccounts=slack', + }, + ] as const)( + 'keeps the $scope.kind setup link and draft callback when the method selector is hidden', + async ({ scope, href }) => { + const onNavigate = vi.fn() + await render({ + scope, + connectorConfig: slackConnectorMeta, + allowAdmin: false, + searchSetupSource: 'slack', + slackSetupOnly: true, + onSetupNavigate: onNavigate, + footer: , + }) + expect(container.querySelector('[role="radiogroup"]')).toBeNull() + expect(container.textContent).toContain('Slack app') + expect(container.textContent).not.toContain('Member accounts') + const link = container.querySelector('a') + expect(link).toHaveAttribute('href', href) + link?.addEventListener('click', (event) => event.preventDefault()) + await act(async () => link?.click()) + expect(onNavigate).toHaveBeenCalledOnce() + expect(container.textContent).toContain('Apply changes') + expect(mocks.accounts).toHaveBeenLastCalledWith(scope) + } + ) it.each(['loading', 'configured'] as const)('hides the Slack detour while %s', async (state) => { mocks.loading = state === 'loading' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx index 79507a3e70b..7e85e99cc36 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx @@ -237,8 +237,8 @@ export function SlackMemberSetup({ }: SlackMemberSetupProps) { const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId }) const href = - scope.kind === 'organization' || searchSetupSource - ? slackSearchSetupHref(scope, searchSetupSource ?? 'search') + scope.kind === 'organization' + ? slackSearchSetupHref(scope.organizationId, searchSetupSource ?? 'search') : `/workspace/${scope.workspaceId}/settings/credential-groups` return ( diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section.tsx deleted file mode 100644 index 0c6da1b9df2..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section.tsx +++ /dev/null @@ -1,99 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { Chip } from '@sim/emcn' -import { connectorDisplayName } from '@/lib/sim-search/connectors' -import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' -import { memberConnectorKeys, type WorkspaceMemberConnector } from '@/hooks/queries/kb/connectors' -import { - CONNECTABLE_MEMBERSHIPS, - describeMembership, - enrollmentActionLabel, - useMemberEnrollment, -} from '@/hooks/use-member-enrollment' - -const SHARED_WITH_YOU_LABEL = 'Shared with you' - -interface MemberConnectorsSectionProps { - workspaceId: string - /** The per-member connectors to show, already narrowed by the page's search. */ - connectors: WorkspaceMemberConnector[] -} - -/** - * The knowledge bases whose connectors sync per member, and where the viewer - * stands with each. Connecting here is the same enrollment the knowledge base - * page offers, so a person can do it from whichever surface they are on. - */ -export function MemberConnectorsSection({ workspaceId, connectors }: MemberConnectorsSectionProps) { - const connectedConnectorIds = useMemo( - () => - new Set( - connectors - .filter((connector) => connector.viewerMembership === 'connected') - .map((connector) => connector.connectorId) - ), - [connectors] - ) - const membershipQueryKeys = useMemo(() => [memberConnectorKeys.list(workspaceId)], [workspaceId]) - const { connect, isAwaiting, isPending, error } = useMemberEnrollment({ - membershipQueryKeys, - connectedConnectorIds, - }) - - if (connectors.length === 0) return null - - return ( - <> - -
- {connectors.map((connector) => { - const meta = CONNECTOR_META_REGISTRY[connector.connectorType] - const name = connectorDisplayName(connector.connectorType) - const waiting = isAwaiting(connector.connectorId) - const state = - describeMembership({ - membership: connector.viewerMembership, - memberSyncStatus: connector.memberSyncStatus, - waiting, - name, - }) ?? 'Connected.' - return ( - - ) : undefined - } - title={name} - description={[connector.knowledgeBaseName, connector.sourceDescription, state] - .filter(Boolean) - .join(' · ')} - trailing={ - CONNECTABLE_MEMBERSHIPS.has(connector.viewerMembership) ? ( - connect(connector.knowledgeBaseId, connector.connectorId)} - disabled={isPending} - > - {enrollmentActionLabel(connector.viewerMembership, waiting)} - - ) : undefined - } - /> - ) - })} -
-
- {error &&

{error}

} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.test.tsx deleted file mode 100644 index 7b3a9fb4384..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** @vitest-environment jsdom */ -import { act, type ReactNode } from 'react' -import { NuqsTestingAdapter } from 'nuqs/adapters/testing' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const push = vi.hoisted(() => vi.fn()) -vi.mock('next/navigation', () => ({ - usePathname: () => '/workspace/workspace-1/settings/credential-groups', - useRouter: () => ({ push }), -})) - -import { SearchSetupReturn } from '@/app/workspace/[workspaceId]/search/components/search-setup-return' - -let root: Root | undefined -let container: HTMLDivElement - -async function render(node: ReactNode, searchParams: string) { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - await act(async () => - root?.render( - - {node} - - ) - ) -} - -beforeEach(() => { - push.mockReset() - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) -}) -afterEach(async () => { - await act(async () => root?.unmount()) - container?.remove() - vi.unstubAllGlobals() -}) - -describe('returning to Search setup', () => { - it.each([ - ['slack', '/workspace/workspace-1/search?addConnector=slack'], - ['search', '/workspace/workspace-1/search'], - ])('returns to the original %s setup', async (source, href) => { - await render(, `?search-setup=${source}`) - await act(async () => container.querySelector('button')?.click()) - expect(push).toHaveBeenCalledWith(href) - }) - - it('lets the existing unsaved-settings guard defer navigation', async () => { - const guard = vi.fn() - await render( - , - '?search-setup=slack' - ) - await act(async () => container.querySelector('button')?.click()) - expect(push).not.toHaveBeenCalled() - expect(guard).toHaveBeenCalledOnce() - guard.mock.calls[0][0]() - expect(push).toHaveBeenCalledWith('/workspace/workspace-1/search?addConnector=slack') - }) - - it('ignores unrecognized destinations', async () => { - await render( - , - '?search-setup=https://unrelated.example' - ) - expect(container.querySelector('button')).toBeNull() - expect(push).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.tsx deleted file mode 100644 index 8f26e606f74..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-setup-return.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' - -import { Chip } from '@sim/emcn' -import { ArrowLeft } from '@sim/emcn/icons' -import { useRouter } from 'next/navigation' -import { useQueryState } from 'nuqs' -import { searchSetupReturnHref } from '@/lib/sim-search/setup-navigation' -import { searchSetupReturnParam } from '@/app/workspace/[workspaceId]/search/search-params' - -interface SearchSetupReturnProps { - workspaceId: string - onNavigate?: (navigate: () => void) => void -} - -/** Rejoins the original source setup from integrations or connected-account settings. */ -export function SearchSetupReturn({ workspaceId, onNavigate }: SearchSetupReturnProps) { - const [source] = useQueryState(searchSetupReturnParam.key, searchSetupReturnParam.parser) - const router = useRouter() - if (!source) return null - const navigate = () => router.push(searchSetupReturnHref(workspaceId, source)) - return ( - (onNavigate ? onNavigate(navigate) : navigate())} - > - Continue Search setup - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx deleted file mode 100644 index 44bf273c7c6..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx +++ /dev/null @@ -1,237 +0,0 @@ -/** @vitest-environment jsdom */ -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors' - -vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({ - IntegrationTile: () => null, -})) - -import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' - -const connect = vi.fn() -const manage = vi.fn() -let root: Root -let container: HTMLDivElement - -function source(overrides: Partial = {}): SearchSourceSummary { - return { - knowledgeBaseId: 'kb-search', - connectorId: 'source-1', - connectorType: 'confluence', - sourceDescription: 'engineering.atlassian.net · ENG', - accessMode: 'members', - availability: 'available', - enabled: true, - isSyncing: false, - lastSyncAt: null, - hasSyncError: false, - viewerDocumentCount: 0, - viewerFailedDocumentCount: 0, - viewerEmailVerified: true, - connectionRequired: true, - viewerMembership: 'invited', - ...overrides, - } as SearchSourceSummary -} - -async function render( - data = source(), - props: { - canAdmin?: boolean - available?: boolean - waiting?: boolean - isPending?: boolean - manageHref?: string - } = {} -) { - await act(async () => - root.render( - - ) - ) -} - -function button(label: string) { - return Array.from(document.querySelectorAll('button')).find( - (node) => node.textContent?.trim() === label || node.getAttribute('aria-label') === label - ) -} - -beforeEach(() => { - vi.clearAllMocks() - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) - -afterEach(async () => { - await act(async () => root.unmount()) - container.remove() - vi.unstubAllGlobals() -}) - -describe('Search source viewer actions', () => { - it.each(['invited', 'not_enrolled'] as const)( - 'lets a %s viewer connect their account', - async (membership) => { - await render(source({ viewerMembership: membership })) - expect(document.body.textContent).toContain('engineering.atlassian.net · ENG') - expect(document.body.textContent).toContain('Connect your account to search this source') - await act(async () => button('Connect account')!.click()) - expect(connect).toHaveBeenCalledOnce() - expect(button('Manage')).toBeUndefined() - } - ) - - it('offers Reconnect and lets a waiting viewer reopen enrollment', async () => { - await render(source({ viewerMembership: 'needs_reauth' })) - expect(button('Reconnect')).toBeDefined() - await render(source({ viewerMembership: 'needs_reauth' }), { waiting: true }) - expect(document.body.textContent).toContain('Finish connecting in the other tab') - await act(async () => button('Open again')!.click()) - expect(connect).toHaveBeenCalledOnce() - await render(source({ viewerMembership: 'needs_reauth' }), { waiting: true, isPending: true }) - expect(button('Open again')?.disabled).toBe(true) - }) - - it.each([ - { change: { availability: 'unavailable' as const }, status: 'Not available in this workspace' }, - { change: { enabled: false }, status: 'Syncing is paused' }, - { change: { viewerEmailVerified: false }, status: 'Verify your email' }, - { change: { viewerMembership: 'unverified_email' as const }, status: 'Verify your email' }, - { change: { viewerMembership: 'revoked' as const }, status: 'Your access was removed' }, - { change: { viewerMembership: null }, status: 'Needs admin attention' }, - ])('blocks connection when $status', async ({ change, status }) => { - await render(source({ ...change, isSyncing: true, hasSyncError: true })) - expect(document.body.textContent).toContain(status) - expect(button('Connect account')).toBeUndefined() - expect(button('Reconnect')).toBeUndefined() - expect(connect).not.toHaveBeenCalled() - }) - - it('blocks a cached available source when the client feature is disabled', async () => { - await render(source(), { available: false }) - expect(document.body.textContent).toContain('Not available in this workspace') - expect(button('Connect account')).toBeUndefined() - }) - - it.each([{ enabled: false }, { approved: false }, { availability: 'unavailable' as const }])( - 'does not offer email recovery while the source is blocked: %o', - async (change) => { - await render(source({ ...change, viewerEmailVerified: false })) - expect(container.querySelector('a[href="/verify"]')).toBeNull() - expect(button('Connect account')).toBeUndefined() - } - ) - - it('prioritizes viewer connection over crawler health for central Confluence identity', async () => { - await render(source({ accessMode: 'admin', hasSyncError: true, isSyncing: true })) - expect(document.body.textContent).toContain('Connect your account to search this source') - expect(button('Connect account')).toBeDefined() - }) - - it.each(['google_drive', 'gitlab'])( - 'shows central %s status without prompting for a member connection', - async (connectorType) => { - await render( - source({ - connectorType, - accessMode: 'admin', - connectionRequired: false, - viewerMembership: null, - viewerDocumentCount: 1, - }) - ) - expect(document.body.textContent).toContain('1 searchable document') - expect(document.body.textContent).not.toContain('Needs admin attention') - expect(button('Connect account')).toBeUndefined() - } - ) - - it.each([ - { - change: { hasSyncError: true, viewerDocumentCount: 4 }, - status: 'Sync needs attention', - }, - { change: { hasSyncError: true }, status: 'Sync needs attention' }, - { - change: { viewerFailedDocumentCount: 1 }, - status: 'Sync needs attention', - }, - { - change: { viewerFailedDocumentCount: 2, viewerDocumentCount: 4 }, - status: 'Sync needs attention', - }, - { - change: { isSyncing: true, viewerDocumentCount: 4 }, - status: 'Indexing', - }, - { change: { isSyncing: true }, status: 'Indexing' }, - { change: { viewerDocumentCount: 4 }, status: '4 searchable documents' }, - { change: { lastSyncAt: '2026-09-05T12:00:00Z' }, status: 'No searchable documents yet' }, - { change: {}, status: 'Waiting for the first sync' }, - ])('reports $status after connection', async ({ change, status }) => { - await render(source({ viewerMembership: 'connected', ...change })) - expect(document.body.textContent).toContain(status) - expect(button('Connect account')).toBeUndefined() - }) - - it('gives admins Manage after connecting and keeps management secondary before connecting', async () => { - await render(source(), { canAdmin: true }) - expect(button('Connect account')).toBeDefined() - expect(button('Confluence source actions')).toBeDefined() - expect(button('Manage')).toBeUndefined() - await render(source({ viewerMembership: 'connected' }), { canAdmin: true }) - await act(async () => button('Manage')!.click()) - expect(manage).toHaveBeenCalledOnce() - expect(connect).not.toHaveBeenCalled() - }) - - it('opens source details through a row link while preserving the separate connection action', async () => { - const manageHref = '/o/org-1/settings/integrations/sources/source-1' - await render(source(), { canAdmin: true, manageHref }) - const link = container.querySelector('a') - expect(link?.getAttribute('href')).toBe(manageHref) - expect(link?.getAttribute('aria-label')).toBe('Open engineering.atlassian.net · ENG') - expect(button('Manage')).toBeUndefined() - expect(button('Confluence source actions')).toBeUndefined() - expect(link?.contains(button('Connect account')!)).toBe(false) - await act(async () => button('Connect account')!.click()) - expect(connect).toHaveBeenCalledOnce() - expect(manage).not.toHaveBeenCalled() - }) - - it('does not expose management navigation to a non-admin', async () => { - await render(source(), { - canAdmin: false, - manageHref: '/o/org-1/settings/integrations/sources/source-1', - }) - expect(container.querySelector('a')).toBeNull() - expect(button('Connect account')).toBeDefined() - }) - - it.each([false, true])( - 'retains the legacy knowledge-base link for canAdmin=%s', - async (canAdmin) => { - await render(source({ connectorType: 'airtable' }), { canAdmin }) - const link = document.querySelector('a') - expect(link?.getAttribute('href')).toBe('/workspace/workspace-1/knowledge/kb-search') - expect(link?.textContent).toBe(canAdmin ? 'Manage' : 'View') - expect(document.body.textContent).toContain('Available in its knowledge base') - expect(button('Connect account')).toBeUndefined() - } - ) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx deleted file mode 100644 index eedbafd5e1f..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx +++ /dev/null @@ -1,120 +0,0 @@ -'use client' - -import type { ReactNode } from 'react' -import { Chip, ChipLink } from '@sim/emcn' -import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors' -import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope' -import { connectorDisplayName } from '@/lib/sim-search/connectors' -import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' -import { getSearchSourceStatus } from '@/app/workspace/[workspaceId]/search/components/search-source-row-status' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' -import { CONNECTABLE_MEMBERSHIPS } from '@/hooks/use-member-enrollment' - -interface SearchSourceRowProps { - source: SearchSourceSummary - workspaceId?: string - scope?: ResourceScope - canAdmin: boolean - available: boolean - waiting: boolean - isPending: boolean - onConnect: () => void - connectLabel?: string - manageHref?: string - /** Opens management for the source; only a surface that offers management passes it. */ - onManage?: () => void - accountActions?: ReactNode -} - -/** Source health and the viewer's connection are separate; only the viewer's next action is primary. */ -export function SearchSourceRow({ - source, - workspaceId, - scope: explicitScope, - canAdmin, - available, - waiting, - isPending, - onConnect, - connectLabel = 'Connect account', - manageHref, - onManage, - accountActions, -}: SearchSourceRowProps) { - const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId }) - const meta = CONNECTOR_META_REGISTRY[source.connectorType] - const name = connectorDisplayName(source.connectorType) - const membership = source.viewerMembership - const usable = available && source.availability === 'available' - const supported = meta?.search === true - const managementHref = canAdmin ? manageHref : undefined - const connectable = - usable && - supported && - source.enabled && - source.approved !== false && - source.viewerEmailVerified && - source.connectionRequired && - membership !== null && - CONNECTABLE_MEMBERSHIPS.has(membership) - const status = getSearchSourceStatus({ - source, - scopeKind: scope.kind, - supported, - usable, - connectable, - waiting, - }) - - return ( - : undefined - } - title={name} - description={[source.sourceDescription, status].filter(Boolean).join(' · ')} - href={managementHref} - clickLabel={managementHref ? `Open ${source.sourceDescription || name}` : undefined} - navigable={Boolean(managementHref)} - trailing={ - !supported && scope.kind === 'workspace' ? ( - - {canAdmin ? 'Manage' : 'View'} - - ) : ( -
- {usable && - supported && - source.enabled && - source.approved !== false && - !source.viewerEmailVerified && Verify email} - {connectable && ( - - {waiting - ? 'Open again' - : membership === 'needs_reauth' - ? 'Reconnect' - : connectLabel} - - )} - {canAdmin && - !managementHref && - onManage && - (connectable ? ( - - ) : ( - Manage - ))} - {accountActions} -
- ) - } - /> - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.test.tsx deleted file mode 100644 index 555d3468c92..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, type ReactNode } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ResourceScope } from '@/lib/core/resource-scope' -import { SearchSourceStatus } from '@/app/workspace/[workspaceId]/search/components/search-source-status' - -const { push, host } = vi.hoisted(() => ({ push: vi.fn(), host: vi.fn() })) -vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) })) -vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ - useOptionalWorkspaceHostContext: host, -})) -vi.mock('@/connectors/registry', () => ({ CONNECTOR_META_REGISTRY: {} })) -vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section', () => ({ - ConnectorsSection: () => null, -})) -vi.mock('@sim/emcn', () => { - const Container = ({ children }: { children: ReactNode }) =>
{children}
- return { - ChipModal: Container, - ChipModalBody: Container, - ChipModalField: Container, - ChipModalHeader: Container, - ChipModalFooter: ({ - primaryAction, - }: { - primaryAction: { label: string; onClick: () => void } - }) => ( - - ), - } -}) - -describe('SearchSourceStatus navigation', () => { - let container: HTMLDivElement - let root: Root - - beforeEach(() => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - container = document.createElement('div') - root = createRoot(container) - push.mockClear() - host.mockReturnValue(null) - }) - - afterEach(() => act(() => root.unmount())) - - function render(scope: ResourceScope) { - act(() => - root.render( - - ) - ) - } - - it('opens the owning organization search route', () => { - render({ kind: 'organization', organizationId: 'org-1' }) - act(() => container.querySelector('button')?.click()) - expect(push).toHaveBeenCalledWith('/o/org-1/search') - }) - - it.each([ - ['workspace-1', true, true, true, true], - ['workspace-1', false, true, true, false], - ['workspace-1', true, false, true, false], - ['workspace-1', true, true, false, false], - ['another-workspace', true, true, true, false], - ])( - 'gates workspace entry using routed host access: %s %s %s %s', - (id, isMember, organizationSearch, knowledgeMemberAccess, visible) => { - host.mockReturnValue({ - workspace: { id }, - hostOrganizationId: 'org-1', - viewer: { isHostOrganizationMember: isMember }, - features: { organizationSearch, knowledgeMemberAccess }, - }) - render({ kind: 'workspace', workspaceId: 'workspace-1' }) - const button = container.querySelector('button') - expect(Boolean(button)).toBe(visible) - if (visible) { - act(() => button?.click()) - expect(push).toHaveBeenCalledWith('/o/org-1/search') - } - } - ) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx deleted file mode 100644 index b9f2ecb2c14..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-status.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client' - -import { - ChipModal, - ChipModalBody, - ChipModalField, - ChipModalFooter, - ChipModalHeader, -} from '@sim/emcn' -import { useRouter } from 'next/navigation' -import type { ResourceScope } from '@/lib/core/resource-scope' -import { organizationRoutes } from '@/lib/navigation/paths' -import { ConnectorsSection } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section' -import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' -import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' -import type { ConnectorData } from '@/hooks/queries/kb/connectors' - -interface SearchSourceStatusProps { - scope: ResourceScope - knowledgeBaseId: string - connectorType: string - connectors: ConnectorData[] - isLoading: boolean - onClose: () => void -} - -/** Search reuses the connector's sync status, history, and recovery controls. */ -export function SearchSourceStatus({ - scope, - knowledgeBaseId, - connectorType, - connectors, - isLoading, - onClose, -}: SearchSourceStatusProps) { - const router = useRouter() - const host = useOptionalWorkspaceHostContext() - const organizationId = - scope.kind === 'organization' - ? scope.organizationId - : host?.workspace.id === scope.workspaceId && - host.viewer.isHostOrganizationMember && - host.features?.organizationSearch && - host.features.knowledgeMemberAccess - ? host.hostOrganizationId - : null - const title = `${CONNECTOR_META_REGISTRY[connectorType]?.name ?? 'Source'} sources` - return ( - { - if (!open) onClose() - }} - srTitle={title} - size='lg' - > - {title} - - - - - - {organizationId && ( - router.push(organizationRoutes(organizationId).search), - }} - /> - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/search/error.tsx b/apps/sim/app/workspace/[workspaceId]/search/error.tsx deleted file mode 100644 index d4520d2d64f..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/search/error.tsx +++ /dev/null @@ -1,15 +0,0 @@ -'use client' - -import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' - -export default function SearchError({ error, reset }: ErrorBoundaryProps) { - return ( - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/search/page.tsx b/apps/sim/app/workspace/[workspaceId]/search/page.tsx deleted file mode 100644 index 83ac1cf864f..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/search/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Suspense } from 'react' -import type { Metadata } from 'next' -import { notFound, redirect } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' -import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components' -import { Search } from '@/app/workspace/[workspaceId]/search/search' - -export const metadata: Metadata = { - title: 'Search', -} - -/** - * Sim Search page entry, served only where per-member access is on: the whole - * surface is the feature, so with it off the route is not there to be typed, - * linked, or bookmarked into — the same judgement the tab header hides itself - * on. The host context is request-memoized, so this re-reads what the workspace - * layout already resolved. - * - * `Search` reads URL query params via nuqs (which uses `useSearchParams` - * internally), so it must sit under a Suspense boundary. The fallback renders - * the real page chrome (background + tab header) so a suspend never shows a - * blank frame. - */ -export default async function SearchPage({ params }: { params: Promise<{ workspaceId: string }> }) { - const session = await getSession() - if (!session?.user) redirect('/login') - - const { workspaceId } = await params - const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id) - if (!hostContext?.features?.knowledgeMemberAccess) notFound() - - return ( - - - - } - > - - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/search/search.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/search.test.tsx deleted file mode 100644 index 2012833a8f7..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/search/search.test.tsx +++ /dev/null @@ -1,410 +0,0 @@ -/** @vitest-environment jsdom */ -import { act } from 'react' -import { NuqsTestingAdapter } from 'nuqs/adapters/testing' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - SearchSourceSummary, - WorkspaceMemberConnector, -} from '@/lib/api/contracts/knowledge/connectors' -import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' - -const mocks = vi.hoisted(() => ({ - canAdmin: false, - features: { knowledgeMemberAccess: true, knowledgeSourceMirroredAccess: true }, - sources: [] as SearchSourceSummary[], - shared: [] as WorkspaceMemberConnector[], - sourcePending: false, - sourceError: null as Error | null, - sharedError: null as Error | null, - sourceRefetch: vi.fn(), - sharedRefetch: vi.fn(), - sourceQuery: vi.fn(), - sharedQuery: vi.fn(), - connect: vi.fn(), - setup: vi.fn(), - sharedRows: vi.fn(), - urlUpdate: vi.fn(), -})) - -vi.mock('next/navigation', () => ({ - useParams: () => ({ workspaceId: 'workspace-1' }), -})) -vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ - useWorkspaceHostContext: () => ({ features: mocks.features }), -})) -vi.mock('@/hooks/use-member-access', () => ({ - useMemberAccessAvailable: () => mocks.features.knowledgeMemberAccess, -})) -vi.mock('@/hooks/queries/workspace', () => ({ - useWorkspacePermissionsQuery: () => ({ data: { viewer: { isAdmin: mocks.canAdmin } } }), -})) -vi.mock('@/hooks/queries/kb/connectors', () => ({ - useSearchSources: (id: string, options: { search: string }) => { - mocks.sourceQuery(id, options) - return { - data: mocks.sources.filter((source) => - `${source.connectorType.replaceAll('_', ' ')} ${source.sourceDescription}` - .toLowerCase() - .includes(options.search.trim().toLowerCase()) - ), - isPending: mocks.sourcePending, - isError: Boolean(mocks.sourceError), - error: mocks.sourceError, - isFetching: false, - refetch: mocks.sourceRefetch, - } - }, - useWorkspaceMemberConnectors: (id: string, options: { enabled: boolean }) => { - mocks.sharedQuery(id, options) - return { - data: mocks.shared, - isError: Boolean(mocks.sharedError), - error: mocks.sharedError, - isFetching: false, - refetch: mocks.sharedRefetch, - } - }, -})) -vi.mock('@/hooks/use-member-enrollment', () => ({ - CONNECTABLE_MEMBERSHIPS: new Set(['needs_reauth', 'invited', 'not_enrolled']), - useMemberEnrollment: () => ({ - connect: mocks.connect, - isAwaiting: () => false, - isPending: false, - error: null, - }), -})) -vi.mock('@/hooks/use-debounced-search-setter', () => ({ - useDebouncedSearchSetter: (write: (value: string) => void) => write, -})) -vi.mock('@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration', () => ({ - useScrollRestoration: () => undefined, -})) -vi.mock('@/app/workspace/[workspaceId]/components', () => ({ IntegrationTabsHeader: () => null })) -vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({ - IntegrationTile: () => null, -})) -vi.mock('@/app/workspace/[workspaceId]/search/components/search-source-setup', () => ({ - SearchSourceSetup: (props: { canAdmin: boolean }) => { - mocks.setup(props) - return
- }, -})) -vi.mock( - '@/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section', - () => ({ - MemberConnectorsSection: (props: { connectors: WorkspaceMemberConnector[] }) => { - mocks.sharedRows(props.connectors) - return props.connectors.length ?
Shared with you
: null - }, - }) -) - -import { Search } from '@/app/workspace/[workspaceId]/search/search' - -function source(overrides: Partial = {}): SearchSourceSummary { - return { - knowledgeBaseId: 'kb-search', - connectorId: 'drive-1', - connectorType: 'google_drive', - sourceDescription: 'Company files', - accessMode: 'admin', - availability: 'available', - enabled: true, - isSyncing: false, - lastSyncAt: '2026-09-05T12:00:00Z', - hasSyncError: false, - viewerDocumentCount: 12, - viewerEmailVerified: true, - connectionRequired: false, - viewerMembership: null, - ...overrides, - } as SearchSourceSummary -} - -function button(label: string) { - return Array.from(document.querySelectorAll('button')).find( - (node) => node.textContent?.trim() === label || node.getAttribute('aria-label') === label - ) -} - -let root: Root -let container: HTMLDivElement -async function render(searchParams = '') { - await act(async () => - root.render( - - - - ) - ) -} - -beforeEach(() => { - vi.clearAllMocks() - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - mocks.canAdmin = false - mocks.features = { knowledgeMemberAccess: true, knowledgeSourceMirroredAccess: true } - mocks.sources = [source(), source({ connectorId: 'gitlab-1', connectorType: 'gitlab' })] - mocks.shared = [] - mocks.sourcePending = false - mocks.sourceError = null - mocks.sharedError = null - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) - -afterEach(async () => { - await act(async () => root.unmount()) - vi.useRealTimers() - container.remove() - vi.unstubAllGlobals() -}) - -describe('unified Search sources', () => { - it('keeps typing responsive and debounces the server search', async () => { - vi.useFakeTimers() - await render() - const input = document.querySelector('input')! - const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set! - await act(async () => { - setValue.call(input, 'git') - input.dispatchEvent(new Event('input', { bubbles: true })) - }) - expect(input.value).toBe('git') - expect(mocks.sourceQuery).toHaveBeenLastCalledWith('workspace-1', { search: '' }) - await act(async () => vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS - 1)) - expect(mocks.sourceQuery).toHaveBeenLastCalledWith('workspace-1', { search: '' }) - await act(async () => vi.advanceTimersByTime(1)) - expect(mocks.sourceQuery).toHaveBeenLastCalledWith('workspace-1', { search: 'git' }) - }) - - it('shows configured central Drive and GitLab sources to readers without setup controls', async () => { - await render() - expect(document.body.textContent).toContain('Google Drive') - expect(document.body.textContent).toContain('GitLab') - expect(document.body.textContent).not.toContain('Slack') - expect(document.body.textContent).not.toContain('Confluence') - expect(button('Add source')).toBeUndefined() - expect(button('Manage')).toBeUndefined() - expect(button('Connect account')).toBeUndefined() - expect(mocks.sourceQuery).toHaveBeenCalledWith('workspace-1', { search: '' }) - expect(mocks.setup).toHaveBeenLastCalledWith(expect.objectContaining({ canAdmin: false })) - }) - - it('connects each configured Confluence site using its exact connector ID', async () => { - mocks.sources = ['engineering', 'sales'].map((site) => - source({ - connectorId: `confluence-${site}`, - connectorType: 'confluence', - sourceDescription: `${site}.atlassian.net`, - accessMode: 'members', - connectionRequired: true, - viewerMembership: 'invited', - }) - ) - await render() - const buttons = Array.from(document.querySelectorAll('button')).filter( - (node) => node.textContent === 'Connect account' - ) - expect(buttons).toHaveLength(2) - expect(document.body.textContent).toContain('engineering.atlassian.net') - expect(document.body.textContent).toContain('sales.atlassian.net') - await act(async () => buttons[1]!.click()) - expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('kb-search', 'confluence-sales') - }) - - it('offers only the member’s required account actions across mixed source methods', async () => { - mocks.sources.push( - source({ - connectorId: 'confluence-central', - connectorType: 'confluence', - connectionRequired: true, - viewerMembership: 'not_enrolled', - }), - source({ - connectorId: 'drive-members', - accessMode: 'members', - connectionRequired: true, - viewerMembership: 'connected', - }), - source({ - connectorId: 'slack-members', - connectorType: 'slack', - accessMode: 'members', - connectionRequired: true, - viewerMembership: 'needs_reauth', - }) - ) - await render() - const actions = Array.from(document.querySelectorAll('button')).map((node) => - node.textContent?.trim() - ) - expect(actions).toEqual(['Connect account', 'Reconnect']) - await act(async () => button('Connect account')!.click()) - await act(async () => button('Reconnect')!.click()) - expect(mocks.connect.mock.calls).toEqual([ - ['kb-search', 'confluence-central'], - ['kb-search', 'slack-members'], - ]) - expect(mocks.urlUpdate).not.toHaveBeenCalled() - }) - - it('keeps central email-based sources usable when managed identities become unavailable', async () => { - mocks.features.knowledgeMemberAccess = false - mocks.sources.push( - source({ - connectorId: 'confluence-central', - connectorType: 'confluence', - connectionRequired: true, - viewerMembership: 'invited', - }), - source({ - connectorId: 'slack-members', - connectorType: 'slack', - accessMode: 'members', - connectionRequired: true, - viewerMembership: 'needs_reauth', - }) - ) - await render() - expect(document.body.textContent?.match(/12 searchable documents/g)).toHaveLength(2) - expect(document.body.textContent?.match(/Not available in this workspace/g)).toHaveLength(2) - expect(button('Connect account')).toBeUndefined() - expect(button('Reconnect')).toBeUndefined() - expect(mocks.sharedQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false }) - }) - - it('allows admins to add member sources when mirrored access is off', async () => { - mocks.canAdmin = true - mocks.features.knowledgeSourceMirroredAccess = false - await render('?search=slack') - expect(button('Add source')).toBeDefined() - await act(async () => button('Add source')!.click()) - await vi.waitFor(() => - expect(mocks.urlUpdate).toHaveBeenCalledWith( - expect.objectContaining({ queryString: '?addConnector=' }) - ) - ) - expect(mocks.setup).toHaveBeenLastCalledWith( - expect.objectContaining({ memberAccessAvailable: true, mirroredAccessAvailable: false }) - ) - }) - - it('keeps general-KB enrollments under Shared with you and excludes index duplicates', async () => { - const shared: WorkspaceMemberConnector = { - knowledgeBaseId: 'kb-sales', - knowledgeBaseName: 'Sales', - knowledgeBaseIsSearchIndex: false, - connectorId: 'sales-drive', - connectorType: 'google_drive', - sourceDescription: 'Sales folder', - memberSyncStatus: 'idle', - viewerMembership: 'invited', - viewerDocumentCount: 0, - } - mocks.shared = [shared, { ...shared, connectorId: 'drive-1', knowledgeBaseIsSearchIndex: true }] - await render() - expect(mocks.sharedRows).toHaveBeenLastCalledWith([shared]) - expect(document.body.textContent).toContain('Shared with you') - }) - - it('blocks cached member and identity actions after features turn off', async () => { - mocks.canAdmin = true - mocks.features = { knowledgeMemberAccess: false, knowledgeSourceMirroredAccess: false } - mocks.sources = [ - source({ accessMode: 'members', connectionRequired: true, viewerMembership: 'invited' }), - source({ - connectorId: 'confluence-1', - connectorType: 'confluence', - connectionRequired: true, - viewerMembership: 'needs_reauth', - }), - ] - await render() - expect(document.body.textContent).toContain('Not available in this workspace') - expect(button('Connect account')).toBeUndefined() - expect(button('Reconnect')).toBeUndefined() - expect(button('Add source')).toBeUndefined() - expect(mocks.sharedQuery).toHaveBeenLastCalledWith('workspace-1', { enabled: false }) - expect(mocks.sharedRows).not.toHaveBeenCalled() - }) - - it.each([false, true])('provides a useful empty state for canAdmin=%s', async (canAdmin) => { - mocks.canAdmin = canAdmin - mocks.sources = [] - await render() - expect(document.body.textContent).toContain( - canAdmin ? 'Add a source to start indexing' : 'Ask a workspace admin to get started' - ) - }) - - it('shows loading and then a retryable source error without stale rows', async () => { - mocks.sourcePending = true - await render() - expect(document.body.textContent).toContain('Loading sources…') - expect(document.body.textContent).not.toContain('Google Drive') - mocks.sourcePending = false - mocks.sourceError = new Error('Could not fetch sources') - await render() - expect(document.body.textContent).toContain('Could not fetch sources') - await act(async () => button('Try again')!.click()) - expect(mocks.sourceRefetch).toHaveBeenCalledOnce() - expect(document.body.textContent).not.toContain('Google Drive') - }) - - it('retries shared-source failures without hiding the configured sources', async () => { - mocks.sharedError = new Error('Shared sources failed') - await render() - expect(document.body.textContent).toContain('Google Drive') - expect(document.body.textContent).toContain('Shared sources failed') - await act(async () => button('Try again')!.click()) - expect(mocks.sharedRefetch).toHaveBeenCalledOnce() - }) - - it('filters by provider and source address while retaining the setup owner for a return URL', async () => { - mocks.canAdmin = true - await render('?search=missing&addConnector=gitlab&credentialDraftId=draft-1') - expect(document.body.textContent).toContain('No matching sources.') - expect(document.body.textContent).not.toContain('Google Drive') - expect(document.querySelector('[data-testid="source-setup"]')).not.toBeNull() - expect(mocks.urlUpdate).not.toHaveBeenCalled() - }) - - it('pushes source management without replacing the filtered list URL used by Back', async () => { - mocks.canAdmin = true - await render('?search=gitlab') - expect(document.body.textContent).toContain('GitLab') - expect(document.body.textContent).not.toContain('Google Drive') - await act(async () => button('Manage')!.click()) - await vi.waitFor(() => - expect(mocks.urlUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - queryString: '?search=gitlab&manage-source=gitlab-1', - options: expect.objectContaining({ history: 'push' }), - }) - ) - ) - await render('?search=gitlab&manage-source=gitlab-1') - await render('?search=gitlab') - expect(document.querySelector('input')?.value).toBe('gitlab') - expect(document.body.textContent).toContain('GitLab') - expect(document.body.textContent).not.toContain('Google Drive') - }) - - it('opens admin source management by connector ID', async () => { - mocks.canAdmin = true - await render() - await act(async () => button('Manage')!.click()) - await vi.waitFor(() => - expect(mocks.urlUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - queryString: '?manage-source=drive-1', - options: expect.objectContaining({ history: 'push' }), - }) - ) - ) - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/search/search.tsx deleted file mode 100644 index 39e424a1bf5..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/search/search.tsx +++ /dev/null @@ -1,189 +0,0 @@ -'use client' - -import { useMemo, useRef } from 'react' -import { Chip, ChipInput } from '@sim/emcn' -import { Plus, Search as SearchIcon } from '@sim/emcn/icons' -import { useParams } from 'next/navigation' -import { useQueryState } from 'nuqs' -import { connectorDisplayName } from '@/lib/sim-search/connectors' -import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' -import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components' -import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section' -import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration' -import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' -import { MemberConnectorsSection } from '@/app/workspace/[workspaceId]/search/components/member-connectors-section/member-connectors-section' -import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination' -import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' -import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup' -import { - connectorSearchParam, - connectorSearchUrlKeys, - managedSourceParam, - searchSetupParam, -} from '@/app/workspace/[workspaceId]/search/search-params' -import { - SettingsEmptyState, - SettingsQueryErrorState, -} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { useSearchSources, useWorkspaceMemberConnectors } from '@/hooks/queries/kb/connectors' -import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' -import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' -import { useDebounce } from '@/hooks/use-debounce' -import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' -import { useMemberAccessAvailable } from '@/hooks/use-member-access' -import { useMemberEnrollment } from '@/hooks/use-member-enrollment' - -/** One source list for everyone; setup and management remain admin actions. */ -export function Search() { - const scrollContainerRef = useRef(null) - const { workspaceId } = useParams<{ workspaceId: string }>() - const { features } = useWorkspaceHostContext() - const memberAccessAvailable = useMemberAccessAvailable() - const mirroredAccessAvailable = features?.knowledgeSourceMirroredAccess === true - const { data: permissions } = useWorkspacePermissionsQuery(workspaceId) - const canAdmin = permissions?.viewer?.isAdmin ?? false - const shared = useWorkspaceMemberConnectors(workspaceId, { enabled: memberAccessAvailable }) - const [searchTerm, setSearchTermParam] = useQueryState(connectorSearchParam.key, { - ...connectorSearchParam.parser, - ...connectorSearchUrlKeys, - }) - const sourceSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) - const sources = useSearchSources(workspaceId, { search: sourceSearch }) - const [, setSelectedType] = useQueryState( - searchSetupParam.key, - searchSetupParam.parser.withOptions({ history: 'replace' }) - ) - const [, setManagedSource] = useQueryState( - managedSourceParam.key, - managedSourceParam.parser.withOptions({ history: 'replace' }) - ) - const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam) - const membershipQueryKeys = useMemo(() => [searchSourceKeys.list(workspaceId)], [workspaceId]) - const connectedConnectorIds = useMemo( - () => - new Set( - sources.data - ?.filter((source) => source.viewerMembership === 'connected') - .map((source) => source.connectorId) - ), - [sources.data] - ) - const { connect, isAwaiting, isPending, error } = useMemberEnrollment({ - membershipQueryKeys, - connectedConnectorIds, - }) - - useScrollRestoration(scrollContainerRef, { ready: !sources.isPending }) - - const normalizedSearch = searchTerm.trim().toLowerCase() - const matches = (type: string, description: string) => - `${connectorDisplayName(type)} ${description}`.toLowerCase().includes(normalizedSearch) - const visibleSources = sources.data ?? [] - const sharedConnectors = memberAccessAvailable - ? (shared.data?.filter( - (source) => - !source.knowledgeBaseIsSearchIndex && - matches( - source.connectorType, - `${source.knowledgeBaseName} ${source.sourceDescription ?? ''}` - ) - ) ?? []) - : [] - - return ( -
- -
-
-
-

Search sources

- {canAdmin && (memberAccessAvailable || mirroredAccessAvailable) && ( - { - void setSearchTermParam('') - void setSelectedType('') - }} - > - Add source - - )} -
- setSearchTerm(event.target.value)} - /> - - {sources.isError && !sources.isFetchNextPageError ? ( - void sources.refetch()} - variant='inline' - /> - ) : sources.isPending ? ( - Loading sources… - ) : visibleSources.length > 0 ? ( - visibleSources.map((source) => ( - connect(source.knowledgeBaseId, source.connectorId)} - onManage={() => void setManagedSource(source.connectorId, { history: 'push' })} - /> - )) - ) : !sources.hasNextPage ? ( - - {normalizedSearch - ? 'No matching sources.' - : canAdmin - ? 'Add a source to start indexing documents for Search.' - : 'Your workspace hasn’t added any sources yet. Ask a workspace admin to get started.'} - - ) : null} - {(!sources.isError || sources.isFetchNextPageError) && ( - - )} - - {memberAccessAvailable && - (shared.isError ? ( - void shared.refetch()} - variant='inline' - /> - ) : ( - - ))} - {error &&

{error}

} - -
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 2b4e50850f6..dc76a2fa158 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -756,12 +756,7 @@ export const Sidebar = memo(function Sidebar() { label: 'Integrations', icon: Integration, href: `/workspace/${workspaceId}/integrations`, - /* Skills and Search are tabs of this surface, not their own nav items — - keep the entry lit while the user is on either. */ - additionalActivePaths: [ - `/workspace/${workspaceId}/skills`, - `/workspace/${workspaceId}/search`, - ], + additionalActivePaths: [`/workspace/${workspaceId}/skills`], hidden: permissionConfig.hideIntegrationsTab, }, ].filter((item) => !item.hidden), diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index dc36523449b..42636ada4f2 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -329,7 +329,7 @@ export interface ConnectorConfigField { export interface ConnectorMeta { /** Restricts new setup and mode changes; existing sources keep their stored access policy. */ supportedAccessModes?: readonly ConnectorAccessMode[] - /** Opts a source into workspace Search after its indexing and permission paths are verified. */ + /** Opts a source into Sim Search after its indexing and permission paths are verified. */ search?: true /** Source setup guide shown only in Search connection flows. */ searchDocsUrl?: string diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index bad80ee1f8b..7776837b1c5 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -9,7 +9,6 @@ import { saveDiscardActions } from '@/components/settings/save-discard-actions' import type { CredentialGroupEnrollment } from '@/lib/api/contracts/credential-groups' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' -import { SearchSetupReturn } from '@/app/workspace/[workspaceId]/search/components/search-setup-return' import { credentialGroupPeopleSearchParam, credentialGroupPeopleSearchUrlKeys, @@ -237,7 +236,6 @@ export function CredentialGroupDetail({ workspaceId, groupId }: CredentialGroupD : undefined } > - {detail.error ? ( {getErrorMessage(detail.error, "Couldn't load connected accounts")} diff --git a/apps/sim/hooks/queries/kb/connectors.test.ts b/apps/sim/hooks/queries/kb/connectors.test.ts index cba05e736ad..ae98455db5a 100644 --- a/apps/sim/hooks/queries/kb/connectors.test.ts +++ b/apps/sim/hooks/queries/kb/connectors.test.ts @@ -52,7 +52,6 @@ import { CONNECTOR_SYNC_POLL_INTERVAL_MS, connectorKeys, isConnectorSyncingOrPending, - memberConnectorKeys, useConnectorDetail, useConnectorDocuments, useConnectorList, @@ -60,26 +59,10 @@ import { useSearchSources, useTriggerSync, useUpdateConnector, - type WorkspaceMemberConnector, } from '@/hooks/queries/kb/connectors' const KB_ID = 'kb-1' -function makeMemberConnector( - overrides: Partial = {} -): WorkspaceMemberConnector { - return { - knowledgeBaseId: KB_ID, - knowledgeBaseName: 'Sim Search', - connectorId: 'connector-1', - connectorType: 'hubspot', - memberSyncStatus: 'idle', - viewerMembership: 'connected', - viewerDocumentCount: 0, - ...overrides, - } -} - function makeConnector(overrides: Partial = {}): ConnectorData { return { id: 'connector-1', @@ -331,61 +314,6 @@ describe('useTriggerSync optimistic state', () => { }) expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: searchSourceKeys.lists() }) }) - - it('queues a members connector in the workspace member-connector list as well', async () => { - const existing = [ - makeConnector({ id: 'connector-1', accessMode: 'members', memberSyncStatus: 'idle' }), - ] - mocks.getQueryData.mockReturnValue(existing) - - useTriggerSync() - const options = capturedMutationOptions() - const context = await options.onMutate({ knowledgeBaseId: KB_ID, connectorId: 'connector-1' }) - - expect(mocks.setQueriesData).toHaveBeenCalledWith( - { queryKey: memberConnectorKeys.lists() }, - expect.any(Function) - ) - const patchMemberList = mocks.setQueriesData.mock.calls.at(-1)?.[1] as ( - connectors: WorkspaceMemberConnector[] | undefined - ) => WorkspaceMemberConnector[] | undefined - const memberList = [ - makeMemberConnector({ connectorId: 'connector-1', memberSyncStatus: 'idle' }), - makeMemberConnector({ connectorId: 'connector-2', memberSyncStatus: 'idle' }), - ] - expect(patchMemberList(memberList)?.map((c) => c.memberSyncStatus)).toEqual(['pending', 'idle']) - expect(patchMemberList(undefined)).toBeUndefined() - - options.onError( - new Error('boom'), - { knowledgeBaseId: KB_ID, connectorId: 'connector-1' }, - context - ) - expect(mocks.invalidateQueries).toHaveBeenCalledWith({ - queryKey: memberConnectorKeys.lists(), - }) - }) - - it('leaves the workspace member-connector list alone for a workspace connector', async () => { - mocks.getQueryData.mockReturnValue([makeConnector({ status: 'active' })]) - - useTriggerSync() - const options = capturedMutationOptions() - const context = await options.onMutate({ knowledgeBaseId: KB_ID, connectorId: 'connector-1' }) - options.onError( - new Error('boom'), - { knowledgeBaseId: KB_ID, connectorId: 'connector-1' }, - context - ) - - expect(mocks.setQueriesData).not.toHaveBeenCalledWith( - { queryKey: memberConnectorKeys.lists() }, - expect.any(Function) - ) - expect(mocks.invalidateQueries).not.toHaveBeenCalledWith({ - queryKey: memberConnectorKeys.lists(), - }) - }) }) describe('direct source detail mutation state', () => { @@ -457,11 +385,6 @@ describe('direct source detail mutation state', () => { mocks.setQueryData.mockClear() mutation.onError(new Error('Sync refused'), variables, previous) expect(detailUpdater()(queued)).toEqual(detail) - if (accessMode === 'members') { - expect(mocks.invalidateQueries).toHaveBeenCalledWith({ - queryKey: memberConnectorKeys.lists(), - }) - } } ) @@ -493,10 +416,6 @@ describe('direct source detail mutation state', () => { useTriggerSync() expect(await capturedMutation().onMutate(variables)).toBeUndefined() expect(mocks.setQueryData).not.toHaveBeenCalled() - expect(mocks.setQueriesData).not.toHaveBeenCalledWith( - { queryKey: memberConnectorKeys.lists() }, - expect.any(Function) - ) } ) diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index e7f89997665..66d5fee637c 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -21,7 +21,6 @@ import { listKnowledgeConnectorDocumentsContract, listKnowledgeConnectorsContract, listSearchSourcesContract, - listWorkspaceMemberConnectorsContract, type MemberSyncLogData, patchKnowledgeConnectorDocumentsContract, type SearchSourceSummary, @@ -33,7 +32,6 @@ import { updateKnowledgeConnectorAccessContract, updateKnowledgeConnectorContract, type ViewerConnectorMembership, - type WorkspaceMemberConnector, } from '@/lib/api/contracts/knowledge' import type { CreateConnectorBody, @@ -70,7 +68,6 @@ import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' export type { SearchSourceSummary, ViewerConnectorMembership, - WorkspaceMemberConnector, ConnectorData, ConnectorDetailData, ConnectorMemberSummary, @@ -266,10 +263,7 @@ function optimisticallySetConnectorStatus( /** * The optimistic "queued" write for a sync trigger, on whichever engine the * connector runs: a members connector queues a member run, so its content - * status must not flip. The Search surface reads the same member sync status - * from the workspace member-connector list, so that cache is queued too; it - * has no poll to reconcile it, and the write cannot stop one, so it is patched - * rather than refetched. Returns what to restore if the trigger is refused. + * status must not flip. Returns what to restore if the trigger is refused. */ function optimisticallyQueueSync( queryClient: QueryClient, @@ -282,15 +276,6 @@ function optimisticallyQueueSync( setCachedConnectorStatus(queryClient, knowledgeBaseId, connectorId, { memberSyncStatus: 'pending', }) - queryClient.setQueriesData( - { queryKey: memberConnectorKeys.lists() }, - (connectors) => - connectors?.map((connector) => - connector.connectorId === connectorId - ? { ...connector, memberSyncStatus: 'pending' } - : connector - ) - ) return { memberSyncStatus: cached.memberSyncStatus } } setCachedConnectorStatus(queryClient, knowledgeBaseId, connectorId, { status: 'pending' }) @@ -332,7 +317,6 @@ export function useCreateConnector() { onSettled: (_data, _error, { knowledgeBaseId }) => { queryClient.invalidateQueries({ queryKey: connectorKeys.all(knowledgeBaseId) }) queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) - queryClient.invalidateQueries({ queryKey: memberConnectorKeys.lists() }) queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }) queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }) void invalidateConnectorAccounts(queryClient) @@ -613,49 +597,6 @@ export function useSearchSources( return summary } -export const memberConnectorKeys = { - all: ['member-connectors'] as const, - lists: () => [...memberConnectorKeys.all, 'list'] as const, - list: (workspaceId?: string) => [...memberConnectorKeys.lists(), workspaceId ?? ''] as const, -} - -export const WORKSPACE_MEMBER_CONNECTORS_STALE_TIME = 30 * 1000 -/** While a connected source is still indexing for the viewer, its state is worth asking for again. */ -const WORKSPACE_MEMBER_CONNECTORS_INDEXING_POLL_MS = 5 * 1000 - -async function fetchWorkspaceMemberConnectors( - workspaceId: string, - signal?: AbortSignal -): Promise { - const response = await requestJson(listWorkspaceMemberConnectorsContract, { - query: { workspaceId }, - signal, - }) - return response.data -} - -/** Workspace sources that let the viewer connect their own account. */ -export function useWorkspaceMemberConnectors( - workspaceId?: string, - options?: { enabled?: boolean } -) { - return useQuery({ - queryKey: memberConnectorKeys.list(workspaceId), - queryFn: ({ signal }) => fetchWorkspaceMemberConnectors(workspaceId as string, signal), - enabled: Boolean(workspaceId) && (options?.enabled ?? true), - staleTime: WORKSPACE_MEMBER_CONNECTORS_STALE_TIME, - refetchInterval: (query) => - query.state.data?.some( - (connector) => - connector.viewerMembership === 'connected' && - (connector.memberSyncStatus === 'pending' || connector.memberSyncStatus === 'running') - ) - ? WORKSPACE_MEMBER_CONNECTORS_INDEXING_POLL_MS - : false, - placeholderData: keepPreviousData, - }) -} - /** Mints the viewer's enrollment link for a per-member connector; the caller navigates to it. */ export function useStartConnectorMemberEnrollment() { const queryClient = useQueryClient() @@ -690,9 +631,7 @@ export function useUpdateConnectorAccess() { queryKey: knowledgeKeys.detail(knowledgeBaseId), exact: true, }) - /** The base list says whether any connector syncs per member, and the Search tab lists them. */ queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) - queryClient.invalidateQueries({ queryKey: memberConnectorKeys.lists() }) queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }) queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }) queryClient.invalidateQueries({ queryKey: knowledgeKeys.searches() }) @@ -731,7 +670,6 @@ export function useDeleteConnector() { */ onSettled: (_data, _error, { knowledgeBaseId, deleteDocuments }) => { queryClient.invalidateQueries({ queryKey: connectorKeys.all(knowledgeBaseId) }) - queryClient.invalidateQueries({ queryKey: memberConnectorKeys.lists() }) queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }) queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }) queryClient.invalidateQueries({ queryKey: knowledgeKeys.documentLists(knowledgeBaseId) }) @@ -778,10 +716,7 @@ export function useTriggerSync() { * takes over through `pending` → `syncing` → `active`. */ onMutate: async ({ knowledgeBaseId, connectorId }) => { - await Promise.all([ - queryClient.cancelQueries({ queryKey: connectorKeys.all(knowledgeBaseId) }), - queryClient.cancelQueries({ queryKey: memberConnectorKeys.lists() }), - ]) + await queryClient.cancelQueries({ queryKey: connectorKeys.all(knowledgeBaseId) }) return optimisticallyQueueSync(queryClient, knowledgeBaseId, connectorId) }, /** @@ -793,14 +728,8 @@ export function useTriggerSync() { if (previous) { setCachedConnectorStatus(queryClient, knowledgeBaseId, connectorId, previous) } - /** - * The member-connector list took the same optimistic `pending`; a refetch - * is its rollback, and the connector list's own status was restored above, - * so it is not refetched over concurrent optimistic patches. - */ - if (previous && 'memberSyncStatus' in previous) { - queryClient.invalidateQueries({ queryKey: memberConnectorKeys.lists() }) - } else { + /** Preserve concurrent member sync patches after restoring this connector. */ + if (!previous || !('memberSyncStatus' in previous)) { queryClient.invalidateQueries({ queryKey: connectorKeys.all(knowledgeBaseId) }) } }, @@ -1049,7 +978,6 @@ export function useConnectSimSearchConnector() { }, onSettled: () => { queryClient.invalidateQueries({ queryKey: searchIndexKeys.details() }) - queryClient.invalidateQueries({ queryKey: memberConnectorKeys.lists() }) queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }) queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }) queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) diff --git a/apps/sim/hooks/queries/personal-source-setup.ts b/apps/sim/hooks/queries/personal-source-setup.ts index 25a3e0598a6..645b8bd8c97 100644 --- a/apps/sim/hooks/queries/personal-source-setup.ts +++ b/apps/sim/hooks/queries/personal-source-setup.ts @@ -8,7 +8,6 @@ import { type PersonalSourceSetupQuery, personalSourceSetupContract, } from '@/lib/api/contracts/knowledge/personal-source-setup' -import { memberConnectorKeys } from '@/hooks/queries/kb/connectors' import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' import { personalSearchIntegrationKeys } from '@/hooks/queries/personal-search-integrations' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' @@ -54,7 +53,6 @@ export function useConnectPersonalSourceSetup() { Promise.all([ client.invalidateQueries({ queryKey: personalSourceSetupKeys.lists() }), client.invalidateQueries({ queryKey: personalSearchIntegrationKeys.lists() }), - client.invalidateQueries({ queryKey: memberConnectorKeys.lists() }), client.invalidateQueries({ queryKey: searchSourceKeys.list({ kind: 'organization', diff --git a/apps/sim/hooks/use-member-enrollment.test.tsx b/apps/sim/hooks/use-member-enrollment.test.tsx index d279c8a818c..4dbd090da1b 100644 --- a/apps/sim/hooks/use-member-enrollment.test.tsx +++ b/apps/sim/hooks/use-member-enrollment.test.tsx @@ -21,7 +21,6 @@ vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }), })) vi.mock('@/hooks/queries/kb/connectors', () => ({ - memberConnectorKeys: { lists: () => ['member-connectors', 'list'] }, useStartConnectorMemberEnrollment: () => ({ mutate: mocks.enrollmentMutate, submittedAt: 0, @@ -56,7 +55,7 @@ function Harness({ onConnectionError?: (message: string) => void }) { latest = useMemberEnrollment({ - membershipQueryKeys: [], + membershipQueryKeys: [['test-memberships']], connectedConnectorIds: connected, directOAuth, onConnectionError, @@ -443,7 +442,7 @@ describe('useMemberEnrollment', () => { act(() => mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'connected' }))) expect(enrollment().isAwaiting('connector-1')).toBe(false) expect(enrollment().error).toBeNull() - expect(mocks.invalidateQueries).toHaveBeenCalled() + expect(mocks.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['test-memberships'] }) expect(mocks.channels[0].close).toHaveBeenCalledOnce() }) diff --git a/apps/sim/hooks/use-member-enrollment.ts b/apps/sim/hooks/use-member-enrollment.ts index 07eb32ba0dd..0b62b215c8a 100644 --- a/apps/sim/hooks/use-member-enrollment.ts +++ b/apps/sim/hooks/use-member-enrollment.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type QueryKey, useQueryClient } from '@tanstack/react-query' +import type { WorkspaceMemberConnector } from '@/lib/api/contracts/knowledge/connectors' import { type ResourceScope, resourceScopeFields, @@ -17,11 +18,9 @@ import { import type { MemberSyncStatus } from '@/lib/knowledge/types' import type { SearchConnector } from '@/lib/sim-search/connectors' import { - memberConnectorKeys, useConnectSimSearchConnector, useStartConnectorMemberEnrollment, type ViewerConnectorMembership, - type WorkspaceMemberConnector, } from '@/hooks/queries/kb/connectors' const logger = createLogger('MemberEnrollment') @@ -120,8 +119,7 @@ interface UseMemberEnrollmentProps { * Lets the viewer connect their own account to a per-member connector, by * connector or by Sim Search source. Enrollment opens in a new tab, and the * membership queries are polled meanwhile so the surface that started it - * updates on its own once the account is connected; the workspace-wide - * membership list is refreshed too, so the other surface catches up as well. + * updates on its own once the account is connected. * * The tab is opened in the click itself, before the enrollment link is * minted, because a tab opened after a network round trip is outside the @@ -158,7 +156,6 @@ export function useMemberEnrollment({ for (const queryKey of membershipQueryKeys) { void queryClient.invalidateQueries({ queryKey }) } - void queryClient.invalidateQueries({ queryKey: memberConnectorKeys.lists() }) }, [membershipQueryKeys, queryClient]) const clearOAuth = (completionId: string) => { diff --git a/apps/sim/hooks/use-oauth-return-hooks.test.tsx b/apps/sim/hooks/use-oauth-return-hooks.test.tsx index f227acf17dd..ff40379d2e5 100644 --- a/apps/sim/hooks/use-oauth-return-hooks.test.tsx +++ b/apps/sim/hooks/use-oauth-return-hooks.test.tsx @@ -144,7 +144,7 @@ beforeEach(() => { NEW_CREDENTIAL, ]) sessionStorage.clear() - window.history.replaceState(null, '', '/workspace/workspace-1/search?addConnector=google_drive') + window.history.replaceState(null, '', '/o/org-1/settings/integrations?addConnector=google_drive') ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index a40f88459b8..61c6c626a93 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -28,8 +28,8 @@ import { import { getDesktopBridge } from '@/lib/desktop' import { organizationRoutes } from '@/lib/navigation/paths' import { stripMicrosoftDataverseEnvironmentFromOAuthCallback } from '@/lib/oauth/microsoft-dataverse' +import { searchSetupAccessParam } from '@/lib/sim-search/search-params' import { organizationSearchSetupPath } from '@/lib/sim-search/setup-navigation' -import { searchSetupAccessParam } from '@/app/workspace/[workspaceId]/search/search-params' import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' import { organizationCredentialKeys, diff --git a/apps/sim/lib/sim-search/connections.ts b/apps/sim/lib/sim-search/connections.ts deleted file mode 100644 index bb3a172ef3c..00000000000 --- a/apps/sim/lib/sim-search/connections.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { WorkspaceMemberConnector } from '@/lib/api/contracts/knowledge/connectors' - -/** The canonical index keeps every configured source, including multiple sites of one provider. */ -export function groupSearchConnections(connectors: readonly WorkspaceMemberConnector[]) { - const connectionByType = new Map() - const sharedConnectors: WorkspaceMemberConnector[] = [] - for (const connector of connectors) { - if (connector.knowledgeBaseIsSearchIndex === true) { - const existing = connectionByType.get(connector.connectorType) - if (existing) existing.push(connector) - else connectionByType.set(connector.connectorType, [connector]) - } else { - sharedConnectors.push(connector) - } - } - return { connectionByType, sharedConnectors } -} diff --git a/apps/sim/app/workspace/[workspaceId]/search/search-params.ts b/apps/sim/lib/sim-search/search-params.ts similarity index 63% rename from apps/sim/app/workspace/[workspaceId]/search/search-params.ts rename to apps/sim/lib/sim-search/search-params.ts index a20092ce98a..3d0116eb784 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/search-params.ts +++ b/apps/sim/lib/sim-search/search-params.ts @@ -36,23 +36,3 @@ export const searchSetupReturnParam = { } as const export type SearchSetupSource = NonNullable> - -/** - * `search` filters the Sim Search connector list by name and description. The - * input is controlled directly by the instant nuqs value; only its URL write is - * debounced via `useDebouncedSearchSetter` — never written on every keystroke. - */ -export const connectorSearchParam = { - key: 'search', - parser: parseAsString.withDefault(''), -} as const - -/** Search is filter view-state: clean URLs, no back-stack churn. */ -export const connectorSearchUrlKeys = { - history: 'replace', - clearOnDefault: true, -} as const - -export type SearchSetupReturnSource = NonNullable< - ReturnType -> diff --git a/apps/sim/lib/sim-search/setup-navigation.test.ts b/apps/sim/lib/sim-search/setup-navigation.test.ts index 25d0be5f7d5..1c41cd7a0c8 100644 --- a/apps/sim/lib/sim-search/setup-navigation.test.ts +++ b/apps/sim/lib/sim-search/setup-navigation.test.ts @@ -1,26 +1,20 @@ -/** - * @vitest-environment node - */ +/** @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { searchSetupReturnHref, slackSearchSetupHref } from '@/lib/sim-search/setup-navigation' +import { + organizationSearchSetupPath, + slackSearchSetupHref, +} from '@/lib/sim-search/setup-navigation' -describe('source setup detours', () => { - it('keeps organization Slack setup and its continuation in the same organization', () => { - const scope = { kind: 'organization' as const, organizationId: 'org-1' } - expect(slackSearchSetupHref(scope, 'slack')).toBe( - '/o/org-1/settings/integrations?search-setup=slack&connectedAccounts=slack' - ) - expect(searchSetupReturnHref(scope, 'slack')).toBe( - '/o/org-1/settings/integrations?addConnector=slack' - ) - expect(searchSetupReturnHref(scope, 'search')).toBe('/o/org-1/settings/integrations') - }) - it('preserves workspace continuation links', () => { - expect(searchSetupReturnHref('workspace-1', 'google_drive')).toBe( - '/workspace/workspace-1/search?addConnector=google_drive' - ) - expect(slackSearchSetupHref('workspace-1', 'slack')).toContain( - '/workspace/workspace-1/settings/credential-groups?search-setup=slack&' - ) +describe('organization source setup', () => { + it('opens organization integrations settings', () => { + expect(organizationSearchSetupPath('org-1')).toBe('/o/org-1/settings/integrations') }) + it.each(['slack', 'search'] as const)( + 'preserves the %s continuation through Slack account setup', + (source) => { + expect(slackSearchSetupHref('org-1', source)).toBe( + `/o/org-1/settings/integrations?search-setup=${source}&connectedAccounts=slack` + ) + } + ) }) diff --git a/apps/sim/lib/sim-search/setup-navigation.ts b/apps/sim/lib/sim-search/setup-navigation.ts index 332e8caf824..4c101dc473a 100644 --- a/apps/sim/lib/sim-search/setup-navigation.ts +++ b/apps/sim/lib/sim-search/setup-navigation.ts @@ -1,50 +1,16 @@ -import type { ResourceScope } from '@/lib/core/resource-scope' import { organizationRoutes } from '@/lib/navigation/paths' -import { - type SearchSetupReturnSource, - searchSetupParam, - searchSetupReturnParam, -} from '@/app/workspace/[workspaceId]/search/search-params' -import { credentialGroupProviderSearchParam } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { searchSetupReturnParam } from '@/lib/sim-search/search-params' -/** Where an organization admin sets up Sim Search sources: the Integrations section of its settings. */ +/** Where an organization admin sets up Sim Search sources. */ export function organizationSearchSetupPath(organizationId: string): string { return organizationRoutes(organizationId).settingsSection('integrations') } -/** Carries Search setup through an existing integration or settings screen. */ -export function searchSetupDestination(path: string, source: SearchSetupReturnSource): string { - const params = new URLSearchParams({ [searchSetupReturnParam.key]: source }) - return `${path}?${params}` -} - -export function searchSetupReturnHref( - owner: string | ResourceScope, - source: SearchSetupReturnSource -): string { - const scope = - typeof owner === 'string' ? { kind: 'workspace' as const, workspaceId: owner } : owner - const path = - scope.kind === 'organization' - ? organizationSearchSetupPath(scope.organizationId) - : `/workspace/${scope.workspaceId}/search` - return source === 'search' - ? path - : `${path}?${new URLSearchParams({ [searchSetupParam.key]: source })}` -} - -/** Opens the Slack account configuration within the existing settings surface. */ -export function slackSearchSetupHref( - owner: string | ResourceScope, - source: 'slack' | 'search' -): string { - const scope = - typeof owner === 'string' ? { kind: 'workspace' as const, workspaceId: owner } : owner - const path = - scope.kind === 'organization' - ? organizationSearchSetupPath(scope.organizationId) - : `/workspace/${scope.workspaceId}/settings/credential-groups` - const providerKey = - scope.kind === 'organization' ? 'connectedAccounts' : credentialGroupProviderSearchParam.key - return `${searchSetupDestination(path, source)}&${new URLSearchParams({ [providerKey]: 'slack' })}` +/** Opens Slack account configuration and preserves the organization setup form. */ +export function slackSearchSetupHref(organizationId: string, source: 'slack' | 'search'): string { + const params = new URLSearchParams({ + [searchSetupReturnParam.key]: source, + connectedAccounts: 'slack', + }) + return `${organizationSearchSetupPath(organizationId)}?${params}` } diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row-status.ts b/apps/sim/lib/sim-search/source-status.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/search/components/search-source-row-status.ts rename to apps/sim/lib/sim-search/source-status.ts