Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1091,7 +1091,7 @@ export function KnowledgeBase({
className={cn(chipVariants({ variant: 'filled' }), 'max-w-[180px]')}
>
<span className='relative flex size-[14px] flex-shrink-0 items-center justify-center'>
{connector.status === 'syncing' ? (
{isConnectorSyncingOrPending(connector) ? (
Comment thread
greptile-apps[bot] marked this conversation as resolved.
<Loader className='size-[14px]' animate />
) : (
ConnectorIcon && <BrandIcon icon={ConnectorIcon} className='size-[14px]' />
Expand Down
105 changes: 101 additions & 4 deletions apps/sim/hooks/queries/kb/connectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,61 @@
* @vitest-environment node
*/

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
requestJson: vi.fn(),
useInfiniteQuery: vi.fn(),
useQuery: vi.fn(),
}))

vi.mock('@tanstack/react-query', () => ({
keepPreviousData: Symbol('keepPreviousData'),
useInfiniteQuery: mocks.useInfiniteQuery,
useMutation: vi.fn(),
useQuery: vi.fn(),
useQuery: mocks.useQuery,
useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
}))

vi.mock('@/lib/api/client/request', () => ({
requestJson: mocks.requestJson,
}))

import { listKnowledgeConnectorDocumentsContract } from '@/lib/api/contracts/knowledge'
import {
type ConnectorData,
listKnowledgeConnectorDocumentsContract,
} from '@/lib/api/contracts/knowledge'
import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE } from '@/lib/knowledge/constants'
import { useConnectorDocuments } from '@/hooks/queries/kb/connectors'
import {
isConnectorSyncingOrPending,
useConnectorDocuments,
useConnectorList,
} from '@/hooks/queries/kb/connectors'

const NOW_MS = new Date('2026-08-21T12:00:00.000Z').getTime()

function makeConnector(overrides: Partial<ConnectorData> = {}): ConnectorData {
const createdAt = new Date(NOW_MS - 60_000).toISOString()

return {
id: 'connector-1',
knowledgeBaseId: 'knowledge-1',
connectorType: 'hubspot',
credentialId: 'credential-1',
sourceConfig: {},
syncMode: 'full',
syncIntervalMinutes: 1440,
status: 'active',
lastSyncAt: null,
lastSyncError: null,
lastSyncDocCount: null,
nextSyncAt: null,
consecutiveFailures: 0,
createdAt,
updatedAt: createdAt,
...overrides,
}
}

interface ConnectorDocumentsPage {
documents: Array<{ id: string }>
Expand All @@ -39,6 +72,70 @@ interface ConnectorDocumentsQueryOptions {
) => number | undefined
}

interface ConnectorListQueryOptions {
notifyOnChangeProps?: 'all'
}

describe('isConnectorSyncingOrPending', () => {
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(NOW_MS)
})

afterEach(() => {
vi.restoreAllMocks()
})

it('treats a recently created active connector without a completed sync as pending', () => {
expect(isConnectorSyncingOrPending(makeConnector())).toBe(true)
})

it('treats a syncing connector as syncing regardless of its age or sync history', () => {
const connector = makeConnector({
status: 'syncing',
createdAt: new Date(NOW_MS - 24 * 60 * 60 * 1000).toISOString(),
lastSyncAt: new Date(NOW_MS - 60 * 60 * 1000).toISOString(),
})

expect(isConnectorSyncingOrPending(connector)).toBe(true)
})

it('does not treat an active connector with a completed sync as pending', () => {
const connector = makeConnector({
lastSyncAt: new Date(NOW_MS - 30_000).toISOString(),
})

expect(isConnectorSyncingOrPending(connector)).toBe(false)
})

it('stops treating an active connector as pending at the two-minute boundary', () => {
const connector = makeConnector({
createdAt: new Date(NOW_MS - 2 * 60 * 1000).toISOString(),
})

expect(isConnectorSyncingOrPending(connector)).toBe(false)
})

it.each(['error', 'paused', 'disabled'] as const)(
'does not treat a recent %s connector as pending',
(status) => {
expect(isConnectorSyncingOrPending(makeConnector({ status }))).toBe(false)
}
)
})

describe('useConnectorList', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('notifies consumers when identical polls complete so pending UI can expire', () => {
useConnectorList('knowledge-1')

const options = mocks.useQuery.mock.calls[0]?.[0] as ConnectorListQueryOptions
expect(options.notifyOnChangeProps).toBe('all')
})
})

describe('useConnectorDocuments', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/hooks/queries/kb/connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export function useConnectorList(knowledgeBaseId?: string) {
enabled: Boolean(knowledgeBaseId),
staleTime: CONNECTOR_LIST_STALE_TIME,
placeholderData: keepPreviousData,
// Pending state is time-based, so identical poll responses must still trigger a render
// for consumers to drop the pending UI when its two-minute window expires.
notifyOnChangeProps: 'all',
refetchInterval: (query) => {
const connectors = query.state.data
if (!connectors?.length) return false
Expand Down
Loading