From bea1a4672b856c20ffadd4f51fb2dd486f34b448 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:34:45 +0100 Subject: [PATCH] Offer one Connect your data tile in Add knowledge The source picker lists what needs no account (upload, URL, crawler, GitHub, wiki) and one tile that opens the Connectors page on the services that sync, in place of a tile per connection under two headings. GitHub keeps its hand-over to connecting an account for private repositories. --- frontend/src/locale/de.json | 8 +- frontend/src/locale/en.json | 8 +- frontend/src/locale/es.json | 8 +- frontend/src/locale/jp.json | 8 +- frontend/src/locale/ru.json | 8 +- frontend/src/locale/zh-TW.json | 8 +- frontend/src/locale/zh.json | 8 +- frontend/src/upload/Upload.test.tsx | 170 +++++++++----------------- frontend/src/upload/Upload.tsx | 149 ++++++---------------- frontend/src/upload/types/ingestor.ts | 6 +- 10 files changed, 123 insertions(+), 258 deletions(-) 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',