diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 9f40585f..50d3b4e0 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1806,16 +1806,16 @@ "endpointUrl": "Benutzerdefinierte Endpunkt-URL (optional)", "initialContent": "Anfangsinhalt (optional)" }, - "groupUploadWeb": "Kein Konto nötig", - "groupConnection": "Aus einer Verbindung", - "tileConnectedAs": "Verbunden als {{account}}", "newCredentials": "Neue Zugangsdaten eingeben", - "tileNotConnected": "Nicht verbunden", "github": { "privateHint": "Privates Repository? Verbinde dein GitHub-Konto, um es zu synchronisieren.", "connectedHint": "Private Repositorys werden über deine GitHub-Verbindung ({{account}}) synchronisiert.", "connect": "GitHub verbinden", "pickRepository": "Repository auswählen" + }, + "connectData": { + "title": "Deine Daten verbinden", + "description": "Google Drive, SharePoint, Confluence und mehr synchronisieren" } }, "createAPIKey": { diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index b2bac081..a257806b 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1812,16 +1812,16 @@ "endpointUrl": "Custom Endpoint URL (optional)", "initialContent": "Initial content (optional)" }, - "groupUploadWeb": "No account needed", - "groupConnection": "From a connection", - "tileConnectedAs": "Connected as {{account}}", "newCredentials": "Enter new credentials", - "tileNotConnected": "Not connected", "github": { "privateHint": "Private repository? Connect your GitHub account to sync it.", "connectedHint": "Private repositories sync from your GitHub connection ({{account}}).", "connect": "Connect GitHub", "pickRepository": "Pick a repository" + }, + "connectData": { + "title": "Connect your data", + "description": "Sync Google Drive, SharePoint, Confluence and more" } }, "createAPIKey": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 6f0f19fe..c39b9f55 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1806,16 +1806,16 @@ "endpointUrl": "URL de endpoint personalizada (opcional)", "initialContent": "Contenido inicial (opcional)" }, - "groupUploadWeb": "Sin cuenta", - "groupConnection": "Desde una conexión", - "tileConnectedAs": "Conectado como {{account}}", "newCredentials": "Introducir credenciales nuevas", - "tileNotConnected": "Sin conectar", "github": { "privateHint": "¿Repositorio privado? Conecta tu cuenta de GitHub para sincronizarlo.", "connectedHint": "Los repositorios privados se sincronizan desde tu conexión de GitHub ({{account}}).", "connect": "Conectar GitHub", "pickRepository": "Elegir un repositorio" + }, + "connectData": { + "title": "Conecta tus datos", + "description": "Sincroniza Google Drive, SharePoint, Confluence y más" } }, "createAPIKey": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 48805126..959de630 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1795,16 +1795,16 @@ "endpointUrl": "カスタムエンドポイント URL(任意)", "initialContent": "初期コンテンツ(任意)" }, - "groupUploadWeb": "アカウント不要", - "groupConnection": "接続から", - "tileConnectedAs": "{{account}} として接続中", "newCredentials": "新しい認証情報を入力", - "tileNotConnected": "未接続", "github": { "privateHint": "プライベートリポジトリですか?同期するには GitHub アカウントを接続してください。", "connectedHint": "プライベートリポジトリは GitHub 接続({{account}})から同期されます。", "connect": "GitHub を接続", "pickRepository": "リポジトリを選択" + }, + "connectData": { + "title": "データを接続", + "description": "Google Drive、SharePoint、Confluence などを同期" } }, "createAPIKey": { diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 7250030f..35ecab3f 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1890,16 +1890,16 @@ "endpointUrl": "Пользовательский URL эндпоинта (необязательно)", "initialContent": "Исходное содержимое (необязательно)" }, - "groupUploadWeb": "Без аккаунта", - "groupConnection": "Из подключения", - "tileConnectedAs": "Подключено как {{account}}", "newCredentials": "Ввести новые учётные данные", - "tileNotConnected": "Не подключено", "github": { "privateHint": "Приватный репозиторий? Подключите аккаунт GitHub, чтобы синхронизировать его.", "connectedHint": "Приватные репозитории синхронизируются через ваше подключение GitHub ({{account}}).", "connect": "Подключить GitHub", "pickRepository": "Выбрать репозиторий" + }, + "connectData": { + "title": "Подключите свои данные", + "description": "Синхронизируйте Google Drive, SharePoint, Confluence и другие сервисы" } }, "createAPIKey": { diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 5b08b384..b0fb5dac 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1795,16 +1795,16 @@ "endpointUrl": "自訂端點 URL(選填)", "initialContent": "初始內容(選填)" }, - "groupUploadWeb": "不需帳號", - "groupConnection": "來自連線", - "tileConnectedAs": "已以 {{account}} 連線", "newCredentials": "輸入新的憑證", - "tileNotConnected": "未連線", "github": { "privateHint": "私人儲存庫?連結你的 GitHub 帳號即可同步。", "connectedHint": "私人儲存庫透過你的 GitHub 連線({{account}})同步。", "connect": "連結 GitHub", "pickRepository": "選擇儲存庫" + }, + "connectData": { + "title": "連接你的資料", + "description": "同步 Google Drive、SharePoint、Confluence 等" } }, "createAPIKey": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index cc01c6ce..58a6f945 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1795,16 +1795,16 @@ "endpointUrl": "自定义端点 URL(可选)", "initialContent": "初始内容(可选)" }, - "groupUploadWeb": "无需账号", - "groupConnection": "来自连接", - "tileConnectedAs": "已作为 {{account}} 连接", "newCredentials": "输入新的凭据", - "tileNotConnected": "未连接", "github": { "privateHint": "私有仓库?连接你的 GitHub 账号即可同步。", "connectedHint": "私有仓库通过你的 GitHub 连接({{account}})同步。", "connect": "连接 GitHub", "pickRepository": "选择仓库" + }, + "connectData": { + "title": "连接你的数据", + "description": "同步 Google Drive、SharePoint、Confluence 等" } }, "createAPIKey": { diff --git a/frontend/src/upload/Upload.test.tsx b/frontend/src/upload/Upload.test.tsx index c688ca01..3a5ac6cf 100644 --- a/frontend/src/upload/Upload.test.tsx +++ b/frontend/src/upload/Upload.test.tsx @@ -8,6 +8,7 @@ vi.mock('react-i18next', () => ({ const connectorsState = vi.hoisted(() => ({ catalog: [] as Record[], connections: [] as Record[], + enabled: true, })); vi.mock('react-redux', () => ({ @@ -17,6 +18,7 @@ vi.mock('react-redux', () => ({ connectors: { catalog: connectorsState.catalog, connections: connectorsState.connections, + enabled: connectorsState.enabled, loaded: true, loading: false, failed: false, @@ -39,6 +41,8 @@ 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 }), })); @@ -81,6 +85,7 @@ describe('Upload source-type tiles', () => { container.remove(); }); + const close = vi.fn(); const render = async () => { await act(async () => { root.render( @@ -89,7 +94,7 @@ describe('Upload source-type tiles', () => { setModalState={vi.fn()} isOnboarding={false} renderTab={null} - close={vi.fn()} + close={close} />, ); }); @@ -129,137 +134,74 @@ describe('Upload source-type tiles', () => { ); }); - it('groups upload and web apart from connections', async () => { - connectorsState.catalog = [ - { key: 's3', icon: 's3', sync_ingestor: 's3', available: true }, - ]; - await render(); - expect(document.body.textContent).toContain( - 'modals.uploadDoc.groupUploadWeb', + const DRIVE = { + key: 'google_drive', + icon: 'drive', + sync_ingestor: 'google_drive', + capabilities: ['sync'], + available: true, + missing_settings: [], + }; + const connectTile = () => + tiles().find((tile) => + tile.textContent?.includes('modals.uploadDoc.connectData.title'), ); - expect(document.body.textContent).toContain( - 'modals.uploadDoc.groupConnection', - ); - connectorsState.catalog = []; - }); - it('hides connector tiles members cannot use', async () => { - connectorsState.catalog = [ - { - key: 'google_drive', - icon: 'drive', - sync_ingestor: 'google_drive', - available: false, - missing_settings: [], - }, - { - key: 's3', - icon: 's3', - sync_ingestor: 's3', - auth_kind: 'api_key', - available: true, - missing_settings: [], - }, - ]; + // One list of what needs no account, then one tile that sends the user to + // connect a service; connected services sync from the Connectors page. + it('lists the no-account types and one Connect your data tile', async () => { + connectorsState.catalog = [DRIVE]; await render(); const labels = tiles().map((tile) => tile.textContent ?? ''); - expect(labels.some((l) => l.includes('ingestors.google_drive.label'))).toBe( - false, - ); - // Not in the catalog at all (needs setup, or turned off): not offered. - expect(labels.some((l) => l.includes('ingestors.confluence.label'))).toBe( - false, - ); - expect(labels.some((l) => l.includes('ingestors.s3.label'))).toBe(true); - expect(document.body.textContent).not.toContain( - 'settings.connectors.status.needsAdminSetup', - ); + for (const type of ['local_file', 'url', 'crawler', 'github', 'wiki']) + expect(labels.some((l) => l.includes(`ingestors.${type}.label`))).toBe( + true, + ); + for (const type of [ + 'google_drive', + 'share_point', + 'confluence', + 's3', + 'reddit', + ]) + expect(labels.some((l) => l.includes(`ingestors.${type}.label`))).toBe( + false, + ); + expect(labels.at(-1)).toContain('modals.uploadDoc.connectData.title'); + expect(labels.at(-1)).toContain('modals.uploadDoc.connectData.description'); + expect(document.body.querySelector('h3')).toBeNull(); connectorsState.catalog = []; }); - it('hides the connection group when no connector is usable', async () => { - connectorsState.catalog = []; + it('opens the Connectors page on the syncing services', async () => { + connectorsState.catalog = [DRIVE]; + navigate.mockClear(); + close.mockClear(); await render(); - expect(document.body.textContent).not.toContain( - 'modals.uploadDoc.groupConnection', + await act(async () => connectTile()!.click()); + expect(close).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith( + '/settings/connectors?capability=sync', ); + expect(launch).not.toHaveBeenCalled(); + connectorsState.catalog = []; }); - it('names the connected account on a connection tile', async () => { + it('offers no Connect tile when no service can sync', async () => { connectorsState.catalog = [ - { - key: 'confluence', - icon: 'confluence', - sync_ingestor: 'confluence', - available: true, - missing_settings: [], - }, - ]; - connectorsState.connections = [ - { - id: 'c1', - connector_key: 'confluence', - status: 'connected', - account_label: 'alex@example.com', - }, + { key: 'telegram', capabilities: ['write'], available: true }, ]; await render(); - const confluence = tiles().find((tile) => - tile.textContent?.includes('modals.uploadDoc.ingestors.confluence.label'), - ); - expect(confluence!.textContent).toContain( - 'modals.uploadDoc.tileConnectedAs', - ); + expect(connectTile()).toBeUndefined(); connectorsState.catalog = []; - connectorsState.connections = []; }); - it('hands a connection tile over to the connect wizard', async () => { - launch.mockClear(); - const s3 = { - key: 's3', - icon: 's3', - sync_ingestor: 's3', - auth_kind: 'api_key', - available: true, - missing_settings: [], - }; - connectorsState.catalog = [s3]; - connectorsState.connections = [ - { id: 'k1', connector_key: 's3', status: 'connected' }, - ]; + it('offers no Connect tile when connectors are off', async () => { + connectorsState.catalog = [DRIVE]; + connectorsState.enabled = false; await render(); - const tile = tiles().find((t) => - t.textContent?.includes('ingestors.s3.label'), - )!; - await act(async () => tile.click()); - // An existing account goes straight to choosing what to sync. - expect(launch).toHaveBeenCalledWith(s3, { - mode: 'sync', - connectionId: 'k1', - purpose: 'knowledge', - }); - connectorsState.catalog = []; - connectorsState.connections = []; - }); - - it('connects a new account for Knowledge, syncing switched on', async () => { - launch.mockClear(); - const s3 = { - key: 's3', - icon: 's3', - sync_ingestor: 's3', - auth_kind: 'api_key', - available: true, - missing_settings: [], - }; - connectorsState.catalog = [s3]; - await render(); - const tile = tiles().find((t) => - t.textContent?.includes('ingestors.s3.label'), - )!; - await act(async () => tile.click()); - expect(launch).toHaveBeenCalledWith(s3, { purpose: 'knowledge' }); + expect(connectTile()).toBeUndefined(); + connectorsState.enabled = true; connectorsState.catalog = []; }); diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index 775616e3..e431c95d 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -1,4 +1,4 @@ -import { ChevronLeft, FileText, Lock } from 'lucide-react'; +import { ChevronLeft, FileText, Lock, Plug } from 'lucide-react'; import { envVar } from '@/env'; import { cn } from '@/lib/utils'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -6,6 +6,7 @@ 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'; @@ -26,11 +27,8 @@ import { Textarea } from '../components/ui/textarea'; import { Modal } from '../components/ui/modal'; import { Separator } from '../components/ui/separator'; import { OptionCard } from '../components/ui/option-card'; -import { SectionHeader } from '../components/ui/section-header'; -import ConnectorIcon from '../connectors/ConnectorIcon'; import ConnectorSetupNotice from '../connectors/ConnectorSetupNotice'; import useConnectorLauncher from '../connectors/useConnectorLauncher'; -import { formatCount } from '../utils/dateTimeUtils'; import { loadConnectors, selectConnections, @@ -110,8 +108,10 @@ function Upload({ const connections = useSelector(selectConnections); const connectorsLoaded = useSelector(selectConnectorsLoaded); const connectorsEnabled = useSelector(selectConnectorsEnabled); - // A connection tile hands over to the connect wizard, the one flow every - // entry point uses; this modal steps aside and closes with it. + 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. const [handedOver, setHandedOver] = useState(false); const { launch, modals: connectModals } = useConnectorLauncher({ onConnected: () => close(), @@ -1151,119 +1151,38 @@ function Upload({ } }; - const connectionTileState = (type: IngestorType) => { - const connector = connectorFor(type); - if (!connector) return undefined; - if (!connector.available) - return t('settings.connectors.status.needsAdminSetup'); - const accounts = connections.filter( - (c) => c.connector_key === connector.key && c.status === 'connected', - ); - if (accounts.length === 1) - return t('modals.uploadDoc.tileConnectedAs', { - 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), - }); - // A status, not a button: the tile itself starts the connection. - return t('modals.uploadDoc.tileNotConnected'); - }; - - // With the catalog loaded, members only see connections they can use: - // a connector that needs admin setup, or that an admin turned off, is not - // offered. Without connectors (or before the catalog loads) every source - // type stays, as before connectors existed. - const offersConnection = (type: IngestorType) => - !connectorsEnabled || !connectorsLoaded || !!connectorFor(type)?.available; + // Services that sync into Knowledge are connected on the Connectors page, + // so the picker offers one way there instead of a tile per service. + const offersConnect = + connectorsEnabled && + connectorCatalog.some((c) => c.capabilities?.includes('sync')); const renderIngestorSelection = () => { - const optionsFor = (types: IngestorType[]) => - types - .map((type) => ingestorOptions.find((o) => o.value === type)) - .filter((option): option is IngestorOption => !!option); - const connectionOptions = optionsFor(CONNECTION_INGESTORS).filter( - (option) => offersConnection(option.value), - ); + const options = UPLOAD_AND_WEB_INGESTORS.map((type) => + ingestorOptions.find((o) => o.value === type), + ).filter((option): option is IngestorOption => !!option); return ( -
-
- + {options.map((option) => ( + + } + title={t(`modals.uploadDoc.ingestors.${option.value}.label`)} + onClick={() => handleIngestorTypeChange(option.value)} + /> + ))} + {offersConnect && ( + } + title={t('modals.uploadDoc.connectData.title')} + description={t('modals.uploadDoc.connectData.description')} + onClick={() => { + handleClose(); + navigate('/settings/connectors?capability=sync'); + }} /> -
- {optionsFor(UPLOAD_AND_WEB_INGESTORS).map((option) => ( - - } - title={t(`modals.uploadDoc.ingestors.${option.value}.label`)} - onClick={() => handleIngestorTypeChange(option.value)} - /> - ))} -
-
- {connectionOptions.length > 0 && ( -
- -
- {connectionOptions.map((option) => { - const connector = connectorFor(option.value); - return ( - - } - title={t( - `modals.uploadDoc.ingestors.${option.value}.label`, - )} - description={connectionTileState(option.value)} - onClick={() => { - if (connectorsEnabled && connector?.available) { - const account = connections.find( - (c) => - c.connector_key === connector.key && - c.status === 'connected', - ); - setHandedOver(true); - // Opened to add knowledge: syncing starts switched on. - launch( - connector, - account - ? { - mode: 'sync', - connectionId: account.id, - purpose: 'knowledge', - } - : { purpose: 'knowledge' }, - ); - return; - } - handleIngestorTypeChange(option.value); - }} - /> - ); - })} -
-
)}
); diff --git a/frontend/src/upload/types/ingestor.ts b/frontend/src/upload/types/ingestor.ts index 57bcd6fa..efba3522 100644 --- a/frontend/src/upload/types/ingestor.ts +++ b/frontend/src/upload/types/ingestor.ts @@ -65,7 +65,11 @@ export interface IngestorSchema { fields: FormField[]; } -/** Add Source groups: what needs no account, and what comes from a connection. */ +/** + * Add Source tiles: what needs no account. The connection types sync from + * the Connectors page; their forms open only when a connector card hands + * one over (`initialIngestor`). + */ export const UPLOAD_AND_WEB_INGESTORS: IngestorType[] = [ 'local_file', 'url',