mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 12:11:45 +00:00
Connect a service from Add knowledge without leaving the page
Connect your data no longer closes the dialog and navigates away, which lost an unsaved agent or chat draft. It lists the services that sync, one per service and in the Connectors page's order (catalog helpers now shared with that page), and picking one opens the connect wizard in place for Knowledge, with a Back to the source types. Only the Knowledge page adds a Browse all connectors link. The tile's description leaves out GitHub, which has its own tile.
This commit is contained in:
1 parent
cdab471c22
commit
6728163f2b
13 files changed
+401
-94
No files matched your search
@@ -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<ConnectorDefinition['state'], number> = {
|
||||
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));
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1768,7 +1768,10 @@
|
||||
"connectData": {
|
||||
"title": "データを接続",
|
||||
"description": "{{services}} を同期",
|
||||
"more": "その他"
|
||||
"more": "その他",
|
||||
"intro": "サービスを選んでサインインし、ナレッジに同期する内容を選びます。",
|
||||
"connectedAs": "{{account}} として接続中",
|
||||
"browseAll": "すべてのコネクタを見る"
|
||||
}
|
||||
},
|
||||
"createAPIKey": {
|
||||
|
||||
@@ -1863,7 +1863,10 @@
|
||||
"connectData": {
|
||||
"title": "Подключите свои данные",
|
||||
"description": "Синхронизируйте {{services}}",
|
||||
"more": "другие сервисы"
|
||||
"more": "другие сервисы",
|
||||
"intro": "Выберите сервис, войдите в аккаунт и выберите, что синхронизировать в знания.",
|
||||
"connectedAs": "Подключено как {{account}}",
|
||||
"browseAll": "Все коннекторы"
|
||||
}
|
||||
},
|
||||
"createAPIKey": {
|
||||
|
||||
@@ -1768,7 +1768,10 @@
|
||||
"connectData": {
|
||||
"title": "連接你的資料",
|
||||
"description": "同步 {{services}}",
|
||||
"more": "更多服務"
|
||||
"more": "更多服務",
|
||||
"intro": "選擇一個服務並登入,然後選擇要同步到知識庫的內容。",
|
||||
"connectedAs": "已以 {{account}} 連線",
|
||||
"browseAll": "瀏覽所有連接器"
|
||||
}
|
||||
},
|
||||
"createAPIKey": {
|
||||
|
||||
@@ -1768,7 +1768,10 @@
|
||||
"connectData": {
|
||||
"title": "连接你的数据",
|
||||
"description": "同步 {{services}}",
|
||||
"more": "更多服务"
|
||||
"more": "更多服务",
|
||||
"intro": "选择一个服务并登录,然后选择要同步到知识库的内容。",
|
||||
"connectedAs": "已作为 {{account}} 连接",
|
||||
"browseAll": "浏览所有连接器"
|
||||
}
|
||||
},
|
||||
"createAPIKey": {
|
||||
|
||||
@@ -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<ConnectorDefinition['state'], number> = {
|
||||
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) => {
|
||||
|
||||
@@ -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<string, unknown>[] },
|
||||
// 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 <div data-testid="where">{location.pathname + location.search}</div>;
|
||||
}
|
||||
|
||||
const render = async (document: Doc) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -116,6 +127,7 @@ describe('Sources access', () => {
|
||||
paginatedDocuments={[document]}
|
||||
handleDeleteDocument={vi.fn()}
|
||||
/>
|
||||
<Where />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -865,6 +865,9 @@ export default function Sources({
|
||||
onSuccessfulUpload={() =>
|
||||
refreshDocs(undefined, currentPage, rowsPerPage)
|
||||
}
|
||||
onBrowseConnectors={() =>
|
||||
navigate('/settings/connectors?capability=sync')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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(
|
||||
<Upload
|
||||
@@ -80,6 +82,7 @@ describe('Upload source-type tiles', () => {
|
||||
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 = [];
|
||||
});
|
||||
|
||||
|
||||
+115
-24
@@ -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 = () => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="sm"
|
||||
onClick={() => setConnecting(false)}
|
||||
className="-ml-3 w-fit justify-start"
|
||||
>
|
||||
<ChevronLeft />
|
||||
<span>{t('modals.uploadDoc.back')}</span>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-foreground text-xl leading-tight font-semibold">
|
||||
{t('modals.uploadDoc.connectData.title')}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('modals.uploadDoc.connectData.intro')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{syncServices.map(({ card, target }) => (
|
||||
<OptionCard
|
||||
key={card.key}
|
||||
icon={<ConnectorIcon icon={card.icon} className="size-6" />}
|
||||
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' },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{onBrowseConnectors && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="inline"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onBrowseConnectors();
|
||||
}}
|
||||
>
|
||||
{t('modals.uploadDoc.connectData.browseAll')}
|
||||
<ArrowRight className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderIngestorSelection = () => {
|
||||
const options = UPLOAD_AND_WEB_INGESTORS.map((type) =>
|
||||
ingestorOptions.find((o) => o.value === type),
|
||||
@@ -788,10 +881,7 @@ function Upload({
|
||||
icon={<Plug />}
|
||||
title={t('modals.uploadDoc.connectData.title')}
|
||||
description={connectDescription}
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
navigate('/settings/connectors?capability=sync');
|
||||
}}
|
||||
onClick={() => setConnecting(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -876,7 +966,7 @@ function Upload({
|
||||
mobileVariant="sheet"
|
||||
>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{!ingestor.type && (
|
||||
{!ingestor.type && !connecting && (
|
||||
<p className="text-foreground text-left text-xl leading-tight font-semibold">
|
||||
{t('modals.uploadDoc.selectSource')}
|
||||
</p>
|
||||
@@ -884,7 +974,8 @@ function Upload({
|
||||
|
||||
{activeTab && (
|
||||
<>
|
||||
{!ingestor.type && renderIngestorSelection()}
|
||||
{!ingestor.type &&
|
||||
(connecting ? renderConnectStep() : renderIngestorSelection())}
|
||||
{ingestor.type && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Button
|
||||
|
||||
Reference in new issue
Block a user