Skip to content
Merged
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 @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
detail: vi.fn(),
integrations: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
documents: vi.fn(),
actions: vi.fn(),
recovery: vi.fn(),
Expand All @@ -23,7 +24,7 @@ const mocks = vi.hoisted(() => ({
save: vi.fn(),
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mocks.push }),
useRouter: () => ({ push: mocks.push, replace: mocks.replace }),
usePathname: () => '/o/org-one/settings/integrations/sources/source-one',
}))
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
Expand Down Expand Up @@ -185,6 +186,17 @@ describe('organization source detail navigation', () => {
expect(button, `Missing ${text}`).toBeTruthy()
await act(async () => button!.click())
}
it.each(['documents', 'settings', 'history'])(
'replaces the removed connection with Sources from the %s view',
async (view) => {
await render(`?view=${view}`)
const options: ConnectorActionsOptions = mocks.actions.mock.lastCall![0]
act(() => options.onRemoved?.())
expect(mocks.replace).toHaveBeenCalledWith('/o/org-one/settings/integrations')
expect(mocks.push).not.toHaveBeenCalled()
}
)

it('opens documents by default and uses the exact canonical search index', async () => {
await render()
expect(mocks.detail).toHaveBeenLastCalledWith('index-one', 'source-one')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ function SourceDetailContent({
const description =
[title === meta?.name ? undefined : meta?.name, status].filter(Boolean).join(' · ') || undefined
const onBack = () => router.push(backHref)
const onRemoved = () =>
router.replace(organizationRoutes(organization.id).settingsSection('integrations'))
const onViewChange = (value: string) => {
const next = sourceViewParam.parser.parse(value)
if (next) void setView(next)
Expand Down Expand Up @@ -254,6 +256,7 @@ function SourceDetailContent({
queryError={integrationFeedback}
backText={backText}
onBack={onBack}
onRemoved={onRemoved}
onViewChange={onViewChange}
/>
)
Expand All @@ -264,7 +267,7 @@ function SourceDetailContent({
title={title}
description={description}
docsLink={meta?.searchDocsUrl}
onRemoved={onBack}
onRemoved={onRemoved}
>
{integrationFeedback}
<SourceNavigation view={view} onViewChange={onViewChange} />
Expand Down Expand Up @@ -367,6 +370,7 @@ interface SourceSettingsEditorProps {
queryError?: ReactNode
backText: string
onBack: () => void
onRemoved: () => void
onViewChange: (view: string) => void
}

Expand Down Expand Up @@ -400,6 +404,7 @@ function SourceSettingsForm({
queryError,
backText,
onBack,
onRemoved,
onViewChange,
onSaved,
onDiscard,
Expand All @@ -420,7 +425,7 @@ function SourceSettingsForm({
description={description}
docsLink={form.docsUrl}
lifecycleDisabled={form.dirty || form.saving}
onRemoved={onBack}
onRemoved={onRemoved}
actions={saveDiscardActions({
dirty: form.dirty,
saving: form.saving,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const {
isFetching: false,
},
lifecycle: {
removeOptions: { onSuccess: undefined as (() => void) | undefined },
sync: { mutate: vi.fn(), reset: vi.fn(), error: null as Error | null, isPending: false },
update: { mutate: vi.fn(), reset: vi.fn(), error: null as Error | null, isPending: false },
remove: { mutate: vi.fn(), reset: vi.fn(), error: null as Error | null, isPending: false },
Expand Down Expand Up @@ -235,7 +236,10 @@ vi.mock('@/hooks/queries/kb/connectors', () => ({
isPlaceholderData: lifecycle.detail.isPlaceholderData,
refetch: lifecycle.detail.refetch,
})),
useDeleteConnector: () => lifecycle.remove,
useDeleteConnector: (options: { onSuccess: () => void }) => {
lifecycle.removeOptions = options
return lifecycle.remove
},
useTriggerSync: () => lifecycle.sync,
useUpdateConnector: () => lifecycle.update,
}))
Expand Down Expand Up @@ -943,15 +947,12 @@ describe('shared connector lifecycle actions', () => {
expect(dialog.textContent).not.toContain('remain unless')
}
act(() => findButton(dialog, 'Remove').click())
expect(lifecycle.remove.mutate).toHaveBeenCalledWith(
{
knowledgeBaseId: 'knowledge-1',
connectorId: 'connector-1',
deleteDocuments: accessMode !== 'workspace',
},
expect.any(Object)
)
act(() => lifecycle.remove.mutate.mock.calls[0][1].onSuccess())
expect(lifecycle.remove.mutate).toHaveBeenCalledWith({
knowledgeBaseId: 'knowledge-1',
connectorId: 'connector-1',
deleteDocuments: accessMode !== 'workspace',
})
act(() => lifecycle.removeOptions.onSuccess?.())
expect(onRemoved).toHaveBeenCalledOnce()
expect(container.querySelector('[role="dialog"]')).toBeNull()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,15 @@ export function useConnectorActions({
}: ConnectorActionsOptions) {
const sync = useTriggerSync()
const update = useUpdateConnector()
const remove = useDeleteConnector()
const [confirmRemove, setConfirmRemove] = useState(false)
const [deleteDocuments, setDeleteDocuments] = useState(false)
const remove = useDeleteConnector({
onSuccess: () => {
setConfirmRemove(false)
setDeleteDocuments(false)
onRemoved?.()
},
})
const requiresDocumentDeletion = connector.accessMode !== 'workspace'
const state = getConnectorSyncState(connector)
const actionsDisabled = disabled || sync.isPending || update.isPending || remove.isPending
Expand Down Expand Up @@ -117,20 +123,11 @@ export function useConnectorActions({
error: remove.error,
onConfirm: () => {
if (!canEdit || actionsDisabled) return
remove.mutate(
{
knowledgeBaseId,
connectorId: connector.id,
deleteDocuments: requiresDocumentDeletion || deleteDocuments,
},
{
onSuccess: () => {
setConfirmRemove(false)
setDeleteDocuments(false)
onRemoved?.()
},
}
)
remove.mutate({
knowledgeBaseId,
connectorId: connector.id,
deleteDocuments: requiresDocumentDeletion || deleteDocuments,
})
},
},
}
Expand Down
40 changes: 40 additions & 0 deletions apps/sim/hooks/queries/kb/connectors-cache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,46 @@ describe('connector Search result cache reconciliation', () => {
})

describe('Search source list reconciliation', () => {
it('runs removal navigation before refetches and retains it after the caller unmounts', async () => {
const client = createQueryClient()
const request = Promise.withResolvers<object>()
mocks.requestJson.mockReturnValueOnce(request.promise)
const invalidated = vi.spyOn(client, 'invalidateQueries')
const onSuccess = vi.fn(() => expect(invalidated).not.toHaveBeenCalled())
const mutation = renderMutation(client, () => useDeleteConnector({ onSuccess }))
let done!: Promise<void>
await act(async () => {
done = mutation().mutateAsync({
knowledgeBaseId: KNOWLEDGE_BASE_ID,
connectorId: CONNECTOR_ID,
deleteDocuments: true,
})
})
act(() => mountedRoots.pop()!.unmount())
request.resolve({ success: true })
await act(async () => {
await done
})
expect(onSuccess).toHaveBeenCalledOnce()
expect(invalidated).toHaveBeenCalledWith({
queryKey: connectorKeys.detail(KNOWLEDGE_BASE_ID, CONNECTOR_ID),
refetchType: 'none',
})
})

it('does not navigate when removal fails', async () => {
const client = createQueryClient()
const onSuccess = vi.fn()
mocks.requestJson.mockRejectedValueOnce(new Error('Removal failed'))
const mutation = renderMutation(client, () => useDeleteConnector({ onSuccess }))
await act(async () => {
await expect(
mutation().mutateAsync({ knowledgeBaseId: KNOWLEDGE_BASE_ID, connectorId: CONNECTOR_ID })
).rejects.toThrow('Removal failed')
})
expect(onSuccess).not.toHaveBeenCalled()
})

it('refreshes summaries after editing source configuration or pausing sync', async () => {
const queryClient = createQueryClient()
const mutation = renderMutation(queryClient, useUpdateConnector)
Expand Down
24 changes: 21 additions & 3 deletions apps/sim/hooks/queries/kb/connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,19 +657,37 @@ async function deleteConnector({
})
}

export function useDeleteConnector() {
interface UseDeleteConnectorOptions {
onSuccess?: () => void
}

export function useDeleteConnector(options?: UseDeleteConnectorOptions) {
const queryClient = useQueryClient()

return useMutation({
mutationFn: deleteConnector,
/** Run before invalidation can unmount the source page on a 404 response. */
onSuccess: () => options?.onSuccess?.(),
/**
* Removing a connector can take its documents with it, so the document
* lists and the base's own totals move — but nothing below them does.
* Invalidating `knowledgeKeys.detail` as a prefix would also refetch every
* cached document detail, chunk page, and chunk search in the base.
*/
onSettled: (_data, _error, { knowledgeBaseId, deleteDocuments }) => {
queryClient.invalidateQueries({ queryKey: connectorKeys.all(knowledgeBaseId) })
onSettled: (_data, error, { knowledgeBaseId, connectorId, deleteDocuments }) => {
if (error) {
queryClient.invalidateQueries({ queryKey: connectorKeys.all(knowledgeBaseId) })
} else {
queryClient.invalidateQueries({ queryKey: connectorKeys.lists(knowledgeBaseId) })
/** Retire stale detail pages without fetching the just-deleted resource during navigation. */
void queryClient.cancelQueries({
queryKey: connectorKeys.detail(knowledgeBaseId, connectorId),
})
queryClient.invalidateQueries({
queryKey: connectorKeys.detail(knowledgeBaseId, connectorId),
refetchType: 'none',
})
}
queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() })
queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() })
queryClient.invalidateQueries({ queryKey: knowledgeKeys.documentLists(knowledgeBaseId) })
Expand Down
Loading
Loading