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.
This commit is contained in:
arc53-machine committed 2026-09-29 18:34:45 +01:00
1 parent c356ad18a9
commit bea1a4672b
10 files changed
+123 -258

No files matched your search

+4 -4
View File
@@ -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": {
+4 -4
View File
@@ -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": {
+4 -4
View File
@@ -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": {
+4 -4
View File
@@ -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": {
+4 -4
View File
@@ -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": {
+4 -4
View File
@@ -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": {
+4 -4
View File
@@ -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": {
+56 -114
View File
@@ -8,6 +8,7 @@ vi.mock('react-i18next', () => ({
const connectorsState = vi.hoisted(() => ({
catalog: [] as Record<string, unknown>[],
connections: [] as Record<string, unknown>[],
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 = [];
});
+34 -115
View File
@@ -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 (
<div className="flex w-full flex-col gap-6">
<section className="flex flex-col gap-3">
<SectionHeader
as="h3"
size="sm"
title={t('modals.uploadDoc.groupUploadWeb')}
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{options.map((option) => (
<OptionCard
key={option.value}
icon={
<img src={option.icon} alt="" className="size-6 dark:invert" />
}
title={t(`modals.uploadDoc.ingestors.${option.value}.label`)}
onClick={() => handleIngestorTypeChange(option.value)}
/>
))}
{offersConnect && (
<OptionCard
icon={<Plug />}
title={t('modals.uploadDoc.connectData.title')}
description={t('modals.uploadDoc.connectData.description')}
onClick={() => {
handleClose();
navigate('/settings/connectors?capability=sync');
}}
/>
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{optionsFor(UPLOAD_AND_WEB_INGESTORS).map((option) => (
<OptionCard
key={option.value}
icon={
<img
src={option.icon}
alt=""
className="size-6 dark:invert"
/>
}
title={t(`modals.uploadDoc.ingestors.${option.value}.label`)}
onClick={() => handleIngestorTypeChange(option.value)}
/>
))}
</div>
</section>
{connectionOptions.length > 0 && (
<section className="flex flex-col gap-3">
<SectionHeader
as="h3"
size="sm"
title={t('modals.uploadDoc.groupConnection')}
/>
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{connectionOptions.map((option) => {
const connector = connectorFor(option.value);
return (
<OptionCard
key={option.value}
icon={
<ConnectorIcon
icon={connector?.icon ?? option.value}
className="size-6"
/>
}
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);
}}
/>
);
})}
</div>
</section>
)}
</div>
);
+5 -1
View File
@@ -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',