diff --git a/frontend/src/connectors/catalogCards.ts b/frontend/src/connectors/catalogCards.ts new file mode 100644 index 00000000..c1694968 --- /dev/null +++ b/frontend/src/connectors/catalogCards.ts @@ -0,0 +1,89 @@ +import type { ConnectorDefinition } from './types'; + +/** + * Connected and needing attention first, then what can be connected, then + * what an admin still has to set up. + */ +export const STATE_ORDER: Record = { + reconnect: 0, + connected: 1, + available: 2, + custom: 3, + needs_setup: 4, + disabled: 5, +}; + +/** Sorts catalog cards by `STATE_ORDER`, keeping catalog order within one. */ +export const byState = (a: ConnectorDefinition, b: ConnectorDefinition) => + STATE_ORDER[a.state] - STATE_ORDER[b.state]; + +/** The parts listed under a parent card (`part_of` names the parent). */ +export const partsOf = (catalog: ConnectorDefinition[], key: string) => + catalog.filter((c) => c.part_of === key); + +/** + * One service offered two ways (Confluence sync and the Jira & Confluence + * MCP actions) is one card. A part is shown on its own only when its + * parent is not listed (not set up, or turned off). + */ +export const isShownUnderParent = ( + catalog: ConnectorDefinition[], + connector: ConnectorDefinition, +) => !!connector.part_of && catalog.some((c) => c.key === connector.part_of); + +/** + * A parent card with its parts folded in: every capability of either, the + * accounts of both, and the state that needs the reader most. + */ +export const mergeParts = ( + catalog: ConnectorDefinition[], + connector: ConnectorDefinition, +): ConnectorDefinition => { + const parts = partsOf(catalog, connector.key); + if (parts.length === 0) return connector; + const all = [connector, ...parts]; + const state = all.some((c) => c.state === 'reconnect') + ? 'reconnect' + : all.some((c) => c.state === 'connected') + ? 'connected' + : connector.state; + return { + ...connector, + capabilities: Array.from(new Set(all.flatMap((c) => c.capabilities))), + connection_count: all.reduce((n, c) => n + c.connection_count, 0), + connected_count: all.reduce((n, c) => n + c.connected_count, 0), + state, + }; +}; + +/** The catalog as the Connectors page lists it: one card per service. */ +export const catalogCards = (catalog: ConnectorDefinition[]) => + catalog + .filter((connector) => !isShownUnderParent(catalog, connector)) + .map((connector) => mergeParts(catalog, connector)); + +/** + * The services a user can connect to sync into Knowledge, in the + * Connectors page's order: each card with the catalog entry that syncs + * (the card itself, or its sync part), and only where that can be + * connected now. + */ +export const syncTargets = (catalog: ConnectorDefinition[]) => + catalogCards(catalog) + .filter((card) => card.capabilities?.includes('sync')) + .map((card) => ({ + card, + // The card's own entry, not the merged card, which has its parts' + // capabilities too. + target: [ + ...catalog.filter((c) => c.key === card.key), + ...partsOf(catalog, card.key), + ].find((c) => c.available && c.capabilities?.includes('sync')), + })) + .filter( + ( + entry, + ): entry is { card: ConnectorDefinition; target: ConnectorDefinition } => + !!entry.target, + ) + .sort((a, b) => byState(a.card, b.card)); diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 78d6ec39..85e01e37 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1779,7 +1779,10 @@ "connectData": { "title": "Deine Daten verbinden", "description": "{{services}} synchronisieren", - "more": "mehr" + "more": "mehr", + "intro": "Wähle einen Dienst, melde dich an und wähle dann, was in Wissen synchronisiert wird.", + "connectedAs": "Verbunden als {{account}}", + "browseAll": "Alle Connectors ansehen" } }, "createAPIKey": { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index f9f58131..c9e13725 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1785,7 +1785,10 @@ "connectData": { "title": "Connect your data", "description": "Sync {{services}}", - "more": "more" + "more": "more", + "intro": "Pick a service, sign in, then choose what to sync into Knowledge.", + "connectedAs": "Connected as {{account}}", + "browseAll": "Browse all connectors" } }, "createAPIKey": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 62aa406c..a5a5bacf 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1779,7 +1779,10 @@ "connectData": { "title": "Conecta tus datos", "description": "Sincroniza {{services}}", - "more": "más" + "more": "más", + "intro": "Elige un servicio, inicia sesión y luego elige qué sincronizar en Conocimiento.", + "connectedAs": "Conectado como {{account}}", + "browseAll": "Ver todos los conectores" } }, "createAPIKey": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 512fcfc4..264167a9 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1768,7 +1768,10 @@ "connectData": { "title": "データを接続", "description": "{{services}} を同期", - "more": "その他" + "more": "その他", + "intro": "サービスを選んでサインインし、ナレッジに同期する内容を選びます。", + "connectedAs": "{{account}} として接続中", + "browseAll": "すべてのコネクタを見る" } }, "createAPIKey": { diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 751e8822..dec57b8f 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1863,7 +1863,10 @@ "connectData": { "title": "Подключите свои данные", "description": "Синхронизируйте {{services}}", - "more": "другие сервисы" + "more": "другие сервисы", + "intro": "Выберите сервис, войдите в аккаунт и выберите, что синхронизировать в знания.", + "connectedAs": "Подключено как {{account}}", + "browseAll": "Все коннекторы" } }, "createAPIKey": { diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 49fe67e3..81bc68ad 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1768,7 +1768,10 @@ "connectData": { "title": "連接你的資料", "description": "同步 {{services}}", - "more": "更多服務" + "more": "更多服務", + "intro": "選擇一個服務並登入,然後選擇要同步到知識庫的內容。", + "connectedAs": "已以 {{account}} 連線", + "browseAll": "瀏覽所有連接器" } }, "createAPIKey": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 8b36f724..c866adf7 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1768,7 +1768,10 @@ "connectData": { "title": "连接你的数据", "description": "同步 {{services}}", - "more": "更多服务" + "more": "更多服务", + "intro": "选择一个服务并登录,然后选择要同步到知识库的内容。", + "connectedAs": "已作为 {{account}} 连接", + "browseAll": "浏览所有连接器" } }, "createAPIKey": { diff --git a/frontend/src/settings/Connectors.tsx b/frontend/src/settings/Connectors.tsx index 8991d76d..c9be1bb9 100644 --- a/frontend/src/settings/Connectors.tsx +++ b/frontend/src/settings/Connectors.tsx @@ -26,6 +26,11 @@ import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group'; import ConnectionDrawer from '../connectors/ConnectionDrawer'; import ConnectorCard from '../connectors/ConnectorCard'; import ConnectorIcon from '../connectors/ConnectorIcon'; +import { + byState, + catalogCards, + partsOf as partsOfCatalog, +} from '../connectors/catalogCards'; import { loadConnectors, selectConnectorCatalog, @@ -55,17 +60,6 @@ const FILTERS = [ ] as const; type Filter = (typeof FILTERS)[number]; -// Connected and needing attention first, then what can be connected, then -// what an admin still has to set up. -const STATE_ORDER: Record = { - reconnect: 0, - connected: 1, - available: 2, - custom: 3, - needs_setup: 4, - disabled: 5, -}; - const isConnected = (connector: ConnectorDefinition) => connector.connection_count > 0; @@ -98,7 +92,8 @@ export default function Connectors() { }, [dispatch, token]); const custom = catalog.filter((c) => c.publisher === 'custom'); - // A link to a part (a tool's "Manage connection") opens its parent's page. + // A `?connector=` link to a part (a Reconnect that can't happen in place) + // opens its parent's page. const openTarget = catalog.find((c) => c.key === openKey); const openConnector = (openTarget?.part_of && @@ -106,29 +101,7 @@ export default function Connectors() { openTarget || null; - // One service offered two ways (Confluence sync and the Jira & Confluence - // MCP actions) is one card. A part is shown on its own only when its - // parent is not listed (not set up, or turned off). - const partsOf = (key: string) => catalog.filter((c) => c.part_of === key); - const isShownUnderParent = (connector: ConnectorDefinition) => - !!connector.part_of && catalog.some((c) => c.key === connector.part_of); - const merged = (connector: ConnectorDefinition): ConnectorDefinition => { - const parts = partsOf(connector.key); - if (parts.length === 0) return connector; - const all = [connector, ...parts]; - const state = all.some((c) => c.state === 'reconnect') - ? 'reconnect' - : all.some((c) => c.state === 'connected') - ? 'connected' - : connector.state; - return { - ...connector, - capabilities: Array.from(new Set(all.flatMap((c) => c.capabilities))), - connection_count: all.reduce((n, c) => n + c.connection_count, 0), - connected_count: all.reduce((n, c) => n + c.connected_count, 0), - state, - }; - }; + const partsOf = (key: string) => partsOfCatalog(catalog, key); // "Connect more" in the composer opens the connectors that can do what the // picker is for: sync content, or give tools. @@ -145,9 +118,7 @@ export default function Connectors() { // Only categories that have something in them once the composer's // capability filter applies (hidden connectors can empty one too), so no // pill leads to an empty page. - const cards = catalog - .filter((connector) => !isShownUnderParent(connector)) - .map(merged); + const cards = catalogCards(catalog); const withCapability = cards.filter((connector) => capability === 'sync' ? connector.capabilities.includes('sync') @@ -181,7 +152,7 @@ export default function Connectors() { connectorDescription(t, c).toLowerCase().includes(query), ), ) - .sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state]); + .sort(byState); }, [withCapability, filter, search, t]); const open = (connector: ConnectorDefinition) => { diff --git a/frontend/src/settings/Sources.test.tsx b/frontend/src/settings/Sources.test.tsx index 20b4208f..b8d511ee 100644 --- a/frontend/src/settings/Sources.test.tsx +++ b/frontend/src/settings/Sources.test.tsx @@ -1,8 +1,9 @@ import { act, useState } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, useLocation } from 'react-router-dom'; -const { dispatch, service, view, connectors } = vi.hoisted(() => ({ +const { dispatch, service, view, connectors, uploadProps } = vi.hoisted(() => ({ + uploadProps: vi.fn(), connectors: { connections: [] as Record[] }, // The heavy children: each view reports the canEdit it was given. view: @@ -70,7 +71,12 @@ vi.mock('./WikiSettingsModal', () => ({ })); vi.mock('./EnableGraphRAGModal', () => ({ default: () => null })); vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null })); -vi.mock('../upload/Upload', () => ({ default: () => null })); +vi.mock('../upload/Upload', () => ({ + default: (props: unknown) => { + uploadProps(props); + return null; + }, +})); import type { Doc } from '../models/misc'; import Sources from './Sources'; @@ -108,6 +114,11 @@ describe('Sources access', () => { document.body.innerHTML = ''; }); + function Where() { + const location = useLocation(); + return
{location.pathname + location.search}
; + } + const render = async (document: Doc) => { await act(async () => { root.render( @@ -116,6 +127,7 @@ describe('Sources access', () => { paginatedDocuments={[document]} handleDeleteDocument={vi.fn()} /> + , ); }); @@ -198,6 +210,24 @@ describe('Sources access', () => { expect(items).toContain('convTile.delete'); }); + // Leaving Knowledge loses nothing, so its Add knowledge may browse the + // whole Connectors page; the other openers keep the list in the dialog. + it('lets Add knowledge browse the syncing connectors from Knowledge', async () => { + await render(doc()); + uploadProps.mockClear(); + const add = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent === 'settings.sources.addSource', + )!; + await act(async () => add.click()); + const props = uploadProps.mock.calls.at(-1)![0] as { + onBrowseConnectors?: () => void; + }; + await act(async () => props.onBrowseConnectors!()); + expect(container.querySelector('[data-testid="where"]')?.textContent).toBe( + '/settings/connectors?capability=sync', + ); + }); + it('a source with no access fields is the caller’s own', async () => { await render(doc()); const items = await menuItems(); diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx index 88914bd7..08bbe018 100644 --- a/frontend/src/settings/Sources.tsx +++ b/frontend/src/settings/Sources.tsx @@ -865,6 +865,9 @@ export default function Sources({ onSuccessfulUpload={() => refreshDocs(undefined, currentPage, rowsPerPage) } + onBrowseConnectors={() => + navigate('/settings/connectors?capability=sync') + } /> )} diff --git a/frontend/src/upload/Upload.test.tsx b/frontend/src/upload/Upload.test.tsx index 96b0df5c..abca6440 100644 --- a/frontend/src/upload/Upload.test.tsx +++ b/frontend/src/upload/Upload.test.tsx @@ -3,8 +3,12 @@ import { createRoot, type Root } from 'react-dom/client'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string, opts?: { services?: string }) => - opts?.services ? `${key}(${opts.services})` : key, + t: (key: string, opts?: { services?: string; account?: string }) => + opts?.services + ? `${key}(${opts.services})` + : opts?.account + ? `${key}(${opts.account})` + : key, i18n: { language: 'en' }, }), })); @@ -45,8 +49,6 @@ vi.mock('../api/services/userService', () => ({ })); const launch = vi.hoisted(() => vi.fn()); -const navigate = vi.hoisted(() => vi.fn()); -vi.mock('react-router-dom', () => ({ useNavigate: () => navigate })); vi.mock('../connectors/useConnectorLauncher', () => ({ default: () => ({ launch, modals: null }), })); @@ -71,7 +73,7 @@ describe('Upload source-type tiles', () => { }); const close = vi.fn(); - const render = async () => { + const render = async (onBrowseConnectors?: () => void) => { await act(async () => { root.render( { isOnboarding={false} renderTab={null} close={close} + onBrowseConnectors={onBrowseConnectors} />, ); }); @@ -122,6 +125,7 @@ describe('Upload source-type tiles', () => { const DRIVE = { key: 'google_drive', name: 'Google Drive', + state: 'available', icon: 'drive', sync_ingestor: 'google_drive', capabilities: ['sync'], @@ -162,33 +166,131 @@ describe('Upload source-type tiles', () => { connectorsState.catalog = []; }); + // Like the Connectors page: connected services first, and a part (a + // service's sync half) listed under its parent. GitHub has its own tile. + const CATALOG = [ + DRIVE, + { ...DRIVE, key: 'share_point', name: 'SharePoint' }, + { ...DRIVE, key: 'github', name: 'GitHub', state: 'connected' }, + { ...DRIVE, key: 's3', name: 'Amazon S3', state: 'connected' }, + { + key: 'mcp:atlassian', + name: 'Atlassian', + capabilities: ['read', 'write'], + available: true, + state: 'available', + }, + { + ...DRIVE, + key: 'confluence', + name: 'Confluence', + part_of: 'mcp:atlassian', + }, + { ...DRIVE, key: 'off', name: 'Needs setup', available: false }, + { key: 'telegram', name: 'Telegram', capabilities: ['write'] }, + ]; + it('names three syncing services, then the rest as more', async () => { - connectorsState.catalog = [ - DRIVE, - { ...DRIVE, key: 'share_point', name: 'SharePoint' }, - { ...DRIVE, key: 'confluence', name: 'Confluence' }, - { ...DRIVE, key: 's3', name: 'Amazon S3' }, - { ...DRIVE, key: 'off', name: 'Needs setup', available: false }, - { key: 'telegram', name: 'Telegram', capabilities: ['write'] }, - ]; + connectorsState.catalog = CATALOG; await render(); expect(connectTile()!.textContent).toContain( - 'modals.uploadDoc.connectData.description(Google Drive, SharePoint, Confluence, and modals.uploadDoc.connectData.more)', + 'modals.uploadDoc.connectData.description(Amazon S3, Google Drive, SharePoint, and modals.uploadDoc.connectData.more)', ); connectorsState.catalog = []; }); - it('opens the Connectors page on the syncing services', async () => { - connectorsState.catalog = [DRIVE]; - navigate.mockClear(); + const serviceTiles = () => + tiles().map( + (tile) => tile.querySelector('[data-slot="card-title"]')?.textContent, + ); + + it('lists the services that sync in place of the tiles, with a way back', async () => { + connectorsState.catalog = CATALOG; close.mockClear(); await render(); await act(async () => connectTile()!.click()); - expect(close).toHaveBeenCalled(); - expect(navigate).toHaveBeenCalledWith( - '/settings/connectors?capability=sync', + expect(close).not.toHaveBeenCalled(); + expect(serviceTiles()).toEqual([ + 'GitHub', + 'Amazon S3', + 'Google Drive', + 'SharePoint', + 'Atlassian', + ]); + const back = Array.from(document.body.querySelectorAll('button')).find( + (b) => b.textContent === 'modals.uploadDoc.back', + )!; + await act(async () => back.click()); + expect(connectTile()).toBeDefined(); + connectorsState.catalog = []; + }); + + it('connects a service in place, for Knowledge', async () => { + connectorsState.catalog = CATALOG; + connectorsState.connections = [ + { + id: 'k1', + connector_key: 's3', + status: 'connected', + account_label: 'bucket-reader', + }, + ]; + launch.mockClear(); + await render(); + await act(async () => connectTile()!.click()); + const tile = (name: string) => + tiles().find( + (t) => + t.querySelector('[data-slot="card-title"]')?.textContent === name, + )!; + expect(tile('Amazon S3').textContent).toContain( + 'modals.uploadDoc.connectData.connectedAs(bucket-reader)', ); - expect(launch).not.toHaveBeenCalled(); + // An account already connected goes straight to choosing what to sync. + await act(async () => tile('Amazon S3').click()); + expect(launch).toHaveBeenLastCalledWith( + expect.objectContaining({ key: 's3' }), + { mode: 'sync', connectionId: 'k1', purpose: 'knowledge' }, + ); + connectorsState.connections = []; + connectorsState.catalog = []; + }); + + it('connects the sync part of a service listed under its parent', async () => { + connectorsState.catalog = CATALOG; + launch.mockClear(); + await render(); + await act(async () => connectTile()!.click()); + const atlassian = tiles().find( + (t) => + t.querySelector('[data-slot="card-title"]')?.textContent === + 'Atlassian', + )!; + await act(async () => atlassian.click()); + expect(launch).toHaveBeenLastCalledWith( + expect.objectContaining({ key: 'confluence' }), + { purpose: 'knowledge' }, + ); + connectorsState.catalog = []; + }); + + it('browses all connectors only when the opener offers it', async () => { + connectorsState.catalog = CATALOG; + const browseLink = () => + Array.from(document.body.querySelectorAll('button')).find( + (b) => b.textContent === 'modals.uploadDoc.connectData.browseAll', + ); + await render(); + await act(async () => connectTile()!.click()); + expect(browseLink()).toBeUndefined(); + + const browse = vi.fn(); + close.mockClear(); + await render(browse); + expect(browseLink()).toBeDefined(); + await act(async () => browseLink()!.click()); + expect(close).toHaveBeenCalled(); + expect(browse).toHaveBeenCalled(); connectorsState.catalog = []; }); diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index e3df3605..e8c070da 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -1,11 +1,10 @@ -import { ChevronLeft, FileText, Lock, Plug } from 'lucide-react'; +import { ArrowRight, ChevronLeft, FileText, Lock, Plug } from 'lucide-react'; import { envVar } from '@/env'; import { useCallback, useEffect, useState } from 'react'; import { nanoid } from '@reduxjs/toolkit'; import type { FileRejection } from 'react-dropzone'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector, useStore } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; import type { RootState } from '../store'; import userService from '../api/services/userService'; @@ -16,9 +15,11 @@ import { FormField as UiFormField } from '../components/ui/form-field'; import { Textarea } from '../components/ui/textarea'; import { Modal } from '../components/ui/modal'; import { OptionCard } from '../components/ui/option-card'; +import ConnectorIcon from '../connectors/ConnectorIcon'; +import { syncTargets } from '../connectors/catalogCards'; import useConnectorLauncher from '../connectors/useConnectorLauncher'; import { connectorName } from '../connectors/i18n'; -import { intlLocale } from '../utils/dateTimeUtils'; +import { formatCount, intlLocale } from '../utils/dateTimeUtils'; import { loadConnectors, selectConnections, @@ -71,6 +72,7 @@ function Upload({ close, onSuccessfulUpload = () => undefined, selectUploadedDoc = true, + onBrowseConnectors, }: { receivedFile: File[]; setModalState: (state: ActiveState) => void; @@ -85,6 +87,12 @@ function Upload({ * uploading never repoints the conversation the user left open. */ selectUploadedDoc?: boolean; + /** + * Opens the Connectors page on the services that sync. Only a caller that + * loses nothing by leaving (the Knowledge page) passes it; the dialog + * closes first. + */ + onBrowseConnectors?: () => void; }) { const token = useSelector(selectToken); const selectedDocs = useSelector(selectSelectedDocs); @@ -92,11 +100,12 @@ function Upload({ const connections = useSelector(selectConnections); const connectorsLoaded = useSelector(selectConnectorsLoaded); const connectorsEnabled = useSelector(selectConnectorsEnabled); - const navigate = useNavigate(); - // GitHub's private-repository hand-over goes to the connect wizard, the - // one flow every entry point uses; this modal steps aside and closes - // with it. + // Connecting a service (Connect your data, GitHub's private-repository + // hand-over) goes to the connect wizard, the one flow every entry point + // uses; this modal steps aside and closes with it. const [handedOver, setHandedOver] = useState(false); + // Connect your data: the services that sync, in place of the tiles. + const [connecting, setConnecting] = useState(false); const { launch, modals: connectModals } = useConnectorLauncher({ onConnected: () => close(), }); @@ -743,29 +752,113 @@ function Upload({ } }; - // Services that sync into Knowledge are connected on the Connectors page, - // so the picker offers one way there instead of a tile per service, named - // after the first few this instance can sync. - const syncServices = connectorsEnabled - ? connectorCatalog - .filter((c) => c.available && c.capabilities?.includes('sync')) - .map((c) => connectorName(t, c)) - : []; + // Services that sync into Knowledge are connected here, through the + // connect wizard: one tile opens their list, named after the first few + // (GitHub has a tile of its own). + const syncServices = connectorsEnabled ? syncTargets(connectorCatalog) : []; + const namedServices = syncServices + .filter(({ card }) => card.key !== 'github') + .map(({ card }) => connectorName(t, card)); const SERVICES_NAMED = 3; const connectDescription = t('modals.uploadDoc.connectData.description', { services: new Intl.ListFormat(intlLocale(i18n.language), { type: 'conjunction', }).format( - syncServices.length > SERVICES_NAMED + namedServices.length > SERVICES_NAMED ? [ - ...syncServices.slice(0, SERVICES_NAMED), + ...namedServices.slice(0, SERVICES_NAMED), t('modals.uploadDoc.connectData.more'), ] - : syncServices, + : namedServices, ), interpolation: { escapeValue: false }, }); + const connectedAccounts = (key: string) => + connections.filter( + (c) => c.connector_key === key && c.status === 'connected', + ); + + /** Which account a service's tile syncs from, when it has any. */ + const accountLine = (key: string) => { + const accounts = connectedAccounts(key); + if (accounts.length === 1) + return t('modals.uploadDoc.connectData.connectedAs', { + account: accounts[0].account_label, + interpolation: { escapeValue: false }, + }); + if (accounts.length > 1) + return t('settings.connectors.status.connectedCount', { + count: accounts.length, + formatted: formatCount(accounts.length), + }); + return undefined; + }; + + const renderConnectStep = () => ( +
+ +
+

+ {t('modals.uploadDoc.connectData.title')} +

+

+ {t('modals.uploadDoc.connectData.intro')} +

+
+
+ {syncServices.map(({ card, target }) => ( + } + title={connectorName(t, card)} + description={accountLine(target.key)} + onClick={() => { + const account = connectedAccounts(target.key)[0]; + setHandedOver(true); + // Opened to add knowledge: syncing starts switched on, and an + // account already connected goes straight to what to sync. + launch( + target, + account + ? { + mode: 'sync', + connectionId: account.id, + purpose: 'knowledge', + } + : { purpose: 'knowledge' }, + ); + }} + /> + ))} +
+ {onBrowseConnectors && ( + + )} +
+ ); + const renderIngestorSelection = () => { const options = UPLOAD_AND_WEB_INGESTORS.map((type) => ingestorOptions.find((o) => o.value === type), @@ -788,10 +881,7 @@ function Upload({ icon={} title={t('modals.uploadDoc.connectData.title')} description={connectDescription} - onClick={() => { - handleClose(); - navigate('/settings/connectors?capability=sync'); - }} + onClick={() => setConnecting(true)} /> )} @@ -876,7 +966,7 @@ function Upload({ mobileVariant="sheet" >
- {!ingestor.type && ( + {!ingestor.type && !connecting && (

{t('modals.uploadDoc.selectSource')}

@@ -884,7 +974,8 @@ function Upload({ {activeTab && ( <> - {!ingestor.type && renderIngestorSelection()} + {!ingestor.type && + (connecting ? renderConnectStep() : renderIngestorSelection())} {ingestor.type && (