mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 04:14:42 +00:00
Point people to connecting their data
- The empty chat screen swaps its last demo card for "Connect your data" while nothing is connected, naming services this install can connect. - A warning dot on Settings and on Settings > Connectors marks a connection that needs signing in again; unlike the toast it stays until the connection is fixed. Connections load once at start for it. - Category pills on the Connectors page follow the composer's capability filter, so none leads to an empty page.
This commit is contained in:
1 parent
df70869dc7
commit
6cbd6091d4
17 files changed
+357
-23
No files matched your search
@@ -0,0 +1,121 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
if (key === 'demo')
|
||||
return [1, 2, 3, 4].map((n) => ({
|
||||
header: `demo${n}`,
|
||||
query: `q${n}`,
|
||||
}));
|
||||
return opts?.names ? `${key}:${opts.names}` : key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./api/services/modelService', () => ({
|
||||
default: {
|
||||
getModels: vi.fn(async () => ({ ok: true, json: async () => ({}) })),
|
||||
transformModels: () => [],
|
||||
},
|
||||
}));
|
||||
vi.mock('./hooks', () => ({ useDarkTheme: () => [false] }));
|
||||
|
||||
import connectorsReducer from './connectors/connectorsSlice';
|
||||
import Hero from './Hero';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const connector = (key: string, name: string, publisher = 'built_in') => ({
|
||||
key,
|
||||
name,
|
||||
publisher,
|
||||
available: true,
|
||||
});
|
||||
|
||||
describe('Hero connect card', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (connections: unknown[]) => {
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
connectors: connectorsReducer,
|
||||
preference: (
|
||||
state = {
|
||||
token: null,
|
||||
selectedModel: null,
|
||||
availableModels: [],
|
||||
modelsLoading: false,
|
||||
},
|
||||
) => state,
|
||||
},
|
||||
preloadedState: {
|
||||
connectors: {
|
||||
enabled: true,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
failed: false,
|
||||
catalog: [
|
||||
connector('google_drive', 'Google Drive'),
|
||||
connector('mcp:notion', 'Notion', 'preset'),
|
||||
connector('custom_mcp', 'MCP server', 'custom'),
|
||||
],
|
||||
connections,
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof configureStore>[0]);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Hero handleQuestion={vi.fn()} />} />
|
||||
<Route
|
||||
path="/settings/connectors"
|
||||
element={<div>CONNECTORS</div>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const card = () =>
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="hero-connect-card"]',
|
||||
);
|
||||
|
||||
it('offers connecting the services this install has', async () => {
|
||||
await render([]);
|
||||
expect(card()?.textContent).toContain(
|
||||
'connectHero.body:Google Drive, Notion',
|
||||
);
|
||||
// It replaces the last demo card.
|
||||
expect(container.textContent).not.toContain('demo4');
|
||||
await act(async () => card()!.click());
|
||||
expect(container.textContent).toContain('CONNECTORS');
|
||||
});
|
||||
|
||||
it('keeps the demo cards once something is connected', async () => {
|
||||
await render([{ id: 'c1', status: 'connected' }]);
|
||||
expect(card()).toBeNull();
|
||||
expect(container.textContent).toContain('demo4');
|
||||
});
|
||||
});
|
||||
+55
-1
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import modelService from './api/services/modelService';
|
||||
@@ -14,6 +15,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './components/ui/select';
|
||||
import {
|
||||
selectConnections,
|
||||
selectConnectorCatalog,
|
||||
selectConnectorsEnabled,
|
||||
selectConnectorsLoaded,
|
||||
} from './connectors/connectorsSlice';
|
||||
import { connectorName } from './connectors/i18n';
|
||||
import { useDarkTheme } from './hooks';
|
||||
import {
|
||||
selectAvailableModels,
|
||||
@@ -145,10 +153,31 @@ export default function Hero({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const navigate = useNavigate();
|
||||
const demos = t('demo', { returnObjects: true }) as Array<{
|
||||
header: string;
|
||||
query: string;
|
||||
}>;
|
||||
// Nothing connected yet: the last card offers connecting a service, named
|
||||
// after what this install can actually connect.
|
||||
const connectorsEnabled = useSelector(selectConnectorsEnabled);
|
||||
const connectorsLoaded = useSelector(selectConnectorsLoaded);
|
||||
const connections = useSelector(selectConnections);
|
||||
const catalog = useSelector(selectConnectorCatalog);
|
||||
const connectable = catalog.filter(
|
||||
(connector) => connector.available && connector.publisher !== 'custom',
|
||||
);
|
||||
const offerConnect =
|
||||
connectorsEnabled &&
|
||||
connectorsLoaded &&
|
||||
connections.length === 0 &&
|
||||
connectable.length > 0;
|
||||
const connectNames = connectable
|
||||
.slice(0, 2)
|
||||
.map((connector) => connectorName(t, connector))
|
||||
.join(', ');
|
||||
const cards = (demos ?? []).filter((demo) => demo.header && demo.query);
|
||||
const shown = offerConnect ? cards.slice(0, 3) : cards;
|
||||
|
||||
return (
|
||||
<div className="text-foreground flex h-full w-full flex-col items-center justify-between">
|
||||
@@ -170,7 +199,7 @@ export default function Hero({
|
||||
{/* Demo Buttons Section */}
|
||||
<div className="mb-3 w-full max-w-full md:mb-3">
|
||||
<div className="grid grid-cols-1 gap-3 text-xs md:grid-cols-1 md:gap-4 lg:grid-cols-2">
|
||||
{demos?.map(
|
||||
{shown.map(
|
||||
(demo: { header: string; query: string }, key: number) =>
|
||||
demo.header &&
|
||||
demo.query && (
|
||||
@@ -199,6 +228,31 @@ export default function Hero({
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
{offerConnect && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={() => navigate('/settings/connectors')}
|
||||
data-testid="hero-connect-card"
|
||||
className={cn(
|
||||
/* eslint-disable-next-line shadcn/no-restyle --
|
||||
Same two-line pill as the demo cards above. */
|
||||
'hidden h-auto w-full flex-col items-start gap-0 py-3.5 text-left text-xs font-normal whitespace-normal md:flex',
|
||||
)}
|
||||
>
|
||||
<p className="text-foreground mb-2 font-semibold">
|
||||
{t('connectHero.title')}
|
||||
</p>
|
||||
<span className="text-muted-foreground line-clamp-2">
|
||||
{t('connectHero.body', {
|
||||
names: connectNames,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
getSectionForPath,
|
||||
type Section,
|
||||
} from './navigation/sections';
|
||||
import ConnectionHealthDot from './connectors/ConnectionHealthDot';
|
||||
import { useSidebarLevel } from './navigation/SidebarLevelProvider';
|
||||
import { useSectionContext } from './navigation/useSectionContext';
|
||||
import { useLastAppPath } from './navigation/useLastAppPath';
|
||||
@@ -798,6 +799,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-foreground text-sm">{t('settings.label')}</p>
|
||||
<ConnectionHealthDot />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ConnectionHealthDot from './ConnectionHealthDot';
|
||||
import connectorsReducer from './connectorsSlice';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('ConnectionHealthDot', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (statuses: string[]) => {
|
||||
const store = configureStore({
|
||||
reducer: { connectors: connectorsReducer },
|
||||
preloadedState: {
|
||||
connectors: {
|
||||
enabled: true,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
failed: false,
|
||||
catalog: [],
|
||||
connections: statuses.map((status, i) => ({ id: `c${i}`, status })),
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof configureStore>[0]);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<ConnectionHealthDot />
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
it('shows while a connection needs signing in again', async () => {
|
||||
await render(['connected', 'reconnect_needed']);
|
||||
const dot = container.querySelector(
|
||||
'[data-testid="connection-health-dot"]',
|
||||
);
|
||||
expect(dot?.getAttribute('aria-label')).toBe(
|
||||
'settings.connectors.health.navDot',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays away when every connection works', async () => {
|
||||
await render(['connected', 'disconnected']);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { selectConnectionsNeedAttention } from './connectorsSlice';
|
||||
|
||||
/**
|
||||
* A warning dot on the nav entries that lead to Connectors while a
|
||||
* connection needs signing in again. Unlike the reconnect toast it does not
|
||||
* expire, so the signal stays until the connection is fixed.
|
||||
*/
|
||||
export default function ConnectionHealthDot() {
|
||||
const { t } = useTranslation();
|
||||
const needsAttention = useSelector(selectConnectionsNeedAttention);
|
||||
if (!needsAttention) return null;
|
||||
return (
|
||||
<span
|
||||
data-testid="connection-health-dot"
|
||||
role="img"
|
||||
aria-label={t('settings.connectors.health.navDot')}
|
||||
className="bg-warning ml-auto size-2 shrink-0 rounded-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -86,6 +86,12 @@ export const selectConnectorCatalog = (state: RootLike) =>
|
||||
state.connectors.catalog;
|
||||
export const selectConnections = (state: RootLike) =>
|
||||
state.connectors.connections;
|
||||
/** A connection that needs the user (signing in again, or failing). */
|
||||
export const selectConnectionsNeedAttention = (state: RootLike) =>
|
||||
(state.connectors?.connections ?? []).some(
|
||||
(connection) =>
|
||||
connection.status === 'reconnect_needed' || connection.status === 'error',
|
||||
);
|
||||
export const selectConnectorsLoading = (state: RootLike) =>
|
||||
state.connectors.loading;
|
||||
export const selectConnectorsLoaded = (state: RootLike) =>
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { setConnectorsEnabled } from '../connectors/connectorsSlice';
|
||||
import {
|
||||
loadConnectors,
|
||||
selectConnectorsEnabled,
|
||||
setConnectorsEnabled,
|
||||
} from '../connectors/connectorsSlice';
|
||||
import { claimLegacySessionTokens } from '../utils/providerUtils';
|
||||
import {
|
||||
getDocs,
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
setSourceDocs,
|
||||
setSpeechAvailability,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import type { AppDispatch } from '../store';
|
||||
|
||||
/**
|
||||
* useDataInitializer Hook
|
||||
@@ -31,7 +36,7 @@ import {
|
||||
* @param isAuthLoading -
|
||||
*/
|
||||
export default function useDataInitializer(isAuthLoading: boolean) {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const token = useSelector(selectToken);
|
||||
const conversations = useSelector(selectConversations);
|
||||
|
||||
@@ -53,6 +58,14 @@ export default function useDataInitializer(isAuthLoading: boolean) {
|
||||
.catch(() => undefined);
|
||||
}, [dispatch]);
|
||||
|
||||
// Connections load once at start so the nav can flag one that needs
|
||||
// signing in again before any connectors page is opened.
|
||||
const connectorsEnabled = useSelector(selectConnectorsEnabled);
|
||||
useEffect(() => {
|
||||
if (isAuthLoading || !connectorsEnabled) return;
|
||||
dispatch(loadConnectors({ token }));
|
||||
}, [isAuthLoading, connectorsEnabled, token, dispatch]);
|
||||
|
||||
// Connector sign-ins used to leave a session token in localStorage. Link
|
||||
// each one to its server-side connection once, then forget it.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1263,7 +1263,8 @@
|
||||
"reconnectSources_other": "{{name}} neu verbinden: {{formatted}} Quellen sind pausiert",
|
||||
"reconnectTools": "{{name}} neu verbinden, damit Agenten es wieder nutzen können",
|
||||
"reconnectBoth_one": "{{name}} neu verbinden: {{formatted}} Quelle ist pausiert und Agenten können es nicht nutzen",
|
||||
"reconnectBoth_other": "{{name}} neu verbinden: {{formatted}} Quellen sind pausiert und Agenten können es nicht nutzen"
|
||||
"reconnectBoth_other": "{{name}} neu verbinden: {{formatted}} Quellen sind pausiert und Agenten können es nicht nutzen",
|
||||
"navDot": "Eine Verbindung braucht Aufmerksamkeit"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "Die gespeicherten Zugangsdaten konnten nicht entschlüsselt werden. Verbinde neu, um fortzufahren.",
|
||||
@@ -2587,5 +2588,9 @@
|
||||
},
|
||||
"prompts": {
|
||||
"textAriaLabel": "Prompt-Text"
|
||||
},
|
||||
"connectHero": {
|
||||
"title": "Daten verbinden",
|
||||
"body": "Chatte mit {{names}} und mehr."
|
||||
}
|
||||
}
|
||||
@@ -1269,7 +1269,8 @@
|
||||
"reconnectSources_other": "Reconnect {{name}}: {{formatted}} sources are paused",
|
||||
"reconnectTools": "Reconnect {{name}} so agents can use it again",
|
||||
"reconnectBoth_one": "Reconnect {{name}}: {{formatted}} source is paused and agents can't use it",
|
||||
"reconnectBoth_other": "Reconnect {{name}}: {{formatted}} sources are paused and agents can't use it"
|
||||
"reconnectBoth_other": "Reconnect {{name}}: {{formatted}} sources are paused and agents can't use it",
|
||||
"navDot": "A connection needs attention"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "Stored credentials could not be decrypted. Reconnect to continue.",
|
||||
@@ -2638,5 +2639,9 @@
|
||||
"model": "{{model}} ({{tokens}} tokens)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connectHero": {
|
||||
"title": "Connect your data",
|
||||
"body": "Chat with {{names}} and more."
|
||||
}
|
||||
}
|
||||
@@ -1263,7 +1263,8 @@
|
||||
"reconnectSources_other": "Vuelve a conectar {{name}}: {{formatted}} fuentes están en pausa",
|
||||
"reconnectTools": "Vuelve a conectar {{name}} para que los agentes puedan usarlo de nuevo",
|
||||
"reconnectBoth_one": "Vuelve a conectar {{name}}: {{formatted}} fuente está en pausa y los agentes no pueden usarlo",
|
||||
"reconnectBoth_other": "Vuelve a conectar {{name}}: {{formatted}} fuentes están en pausa y los agentes no pueden usarlo"
|
||||
"reconnectBoth_other": "Vuelve a conectar {{name}}: {{formatted}} fuentes están en pausa y los agentes no pueden usarlo",
|
||||
"navDot": "Una conexión necesita atención"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "No se pudieron descifrar las credenciales guardadas. Reconecta para continuar.",
|
||||
@@ -2587,5 +2588,9 @@
|
||||
},
|
||||
"prompts": {
|
||||
"textAriaLabel": "Texto del prompt"
|
||||
},
|
||||
"connectHero": {
|
||||
"title": "Conecta tus datos",
|
||||
"body": "Chatea con {{names}} y más."
|
||||
}
|
||||
}
|
||||
@@ -1247,7 +1247,8 @@
|
||||
"reconnect": "同期を続けるには {{name}} を再接続してください。",
|
||||
"reconnectSources_other": "{{name}} を再接続してください: {{formatted}} 件のソースが一時停止中です",
|
||||
"reconnectTools": "エージェントが再び使えるよう {{name}} を再接続してください",
|
||||
"reconnectBoth_other": "{{name}} を再接続してください: {{formatted}} 件のソースが一時停止中で、エージェントも使えません"
|
||||
"reconnectBoth_other": "{{name}} を再接続してください: {{formatted}} 件のソースが一時停止中で、エージェントも使えません",
|
||||
"navDot": "接続に対応が必要です"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "保存された認証情報を復号できませんでした。続行するには再接続してください。",
|
||||
@@ -2566,5 +2567,9 @@
|
||||
},
|
||||
"prompts": {
|
||||
"textAriaLabel": "プロンプトテキスト"
|
||||
},
|
||||
"connectHero": {
|
||||
"title": "データを接続",
|
||||
"body": "{{names}} などとチャットできます。"
|
||||
}
|
||||
}
|
||||
@@ -1333,7 +1333,8 @@
|
||||
"reconnectBoth_one": "Переподключите {{name}}: {{formatted}} источник приостановлен, агенты не могут его использовать",
|
||||
"reconnectBoth_few": "Переподключите {{name}}: {{formatted}} источника приостановлены, агенты не могут его использовать",
|
||||
"reconnectBoth_many": "Переподключите {{name}}: {{formatted}} источников приостановлены, агенты не могут его использовать",
|
||||
"reconnectBoth_other": "Переподключите {{name}}: {{formatted}} источника приостановлены, агенты не могут его использовать"
|
||||
"reconnectBoth_other": "Переподключите {{name}}: {{formatted}} источника приостановлены, агенты не могут его использовать",
|
||||
"navDot": "Подключению требуется внимание"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "Не удалось расшифровать сохранённые учётные данные. Переподключитесь, чтобы продолжить.",
|
||||
@@ -2681,5 +2682,9 @@
|
||||
},
|
||||
"prompts": {
|
||||
"textAriaLabel": "Текст подсказки"
|
||||
},
|
||||
"connectHero": {
|
||||
"title": "Подключите данные",
|
||||
"body": "Общайтесь с {{names}} и не только."
|
||||
}
|
||||
}
|
||||
@@ -1247,7 +1247,8 @@
|
||||
"reconnect": "重新連線 {{name}} 以繼續同步。",
|
||||
"reconnectSources_other": "請重新連線 {{name}}:{{formatted}} 個來源已暫停",
|
||||
"reconnectTools": "請重新連線 {{name}},讓代理可以再次使用它",
|
||||
"reconnectBoth_other": "請重新連線 {{name}}:{{formatted}} 個來源已暫停,代理也無法使用它"
|
||||
"reconnectBoth_other": "請重新連線 {{name}}:{{formatted}} 個來源已暫停,代理也無法使用它",
|
||||
"navDot": "有連線需要處理"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "無法解密已儲存的憑證。請重新連線以繼續。",
|
||||
@@ -2566,5 +2567,9 @@
|
||||
},
|
||||
"prompts": {
|
||||
"textAriaLabel": "提示文字"
|
||||
},
|
||||
"connectHero": {
|
||||
"title": "連線你的資料",
|
||||
"body": "與 {{names}} 等進行對話。"
|
||||
}
|
||||
}
|
||||
@@ -1247,7 +1247,8 @@
|
||||
"reconnect": "重新连接 {{name}} 以继续同步。",
|
||||
"reconnectSources_other": "请重新连接 {{name}}:{{formatted}} 个来源已暂停",
|
||||
"reconnectTools": "请重新连接 {{name}},让智能体可以再次使用它",
|
||||
"reconnectBoth_other": "请重新连接 {{name}}:{{formatted}} 个来源已暂停,智能体也无法使用它"
|
||||
"reconnectBoth_other": "请重新连接 {{name}}:{{formatted}} 个来源已暂停,智能体也无法使用它",
|
||||
"navDot": "有连接需要处理"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "无法解密保存的凭据。请重新连接以继续。",
|
||||
@@ -2566,5 +2567,9 @@
|
||||
},
|
||||
"prompts": {
|
||||
"textAriaLabel": "提示文本"
|
||||
},
|
||||
"connectHero": {
|
||||
"title": "连接你的数据",
|
||||
"body": "与 {{names}} 等进行对话。"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import ConnectionHealthDot from '../connectors/ConnectionHealthDot';
|
||||
import { selectConnectorsEnabled } from '../connectors/connectorsSlice';
|
||||
import { getVisibleGroups, type Section, type SectionItem } from './sections';
|
||||
import { useSidebarLevel } from './SidebarLevelProvider';
|
||||
@@ -69,6 +70,7 @@ export default function SectionNav({
|
||||
>
|
||||
<Icon className="text-muted-foreground size-5 shrink-0" aria-hidden />
|
||||
<span className="truncate">{t(item.labelKey)}</span>
|
||||
{item.path === '/settings/connectors' && <ConnectionHealthDot />}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -236,6 +236,16 @@ describe('Connectors page', () => {
|
||||
expect(pills).not.toContain('settings.connectors.categories.database');
|
||||
});
|
||||
|
||||
it('offers no category that the capability filter would leave empty', async () => {
|
||||
await render('/settings/connectors?capability=sync');
|
||||
const pills = Array.from(
|
||||
container.querySelectorAll('[data-slot="toggle-group-item"]'),
|
||||
).map((item) => item.textContent);
|
||||
expect(pills).toContain('settings.connectors.categories.files');
|
||||
// Telegram (messaging) cannot sync.
|
||||
expect(pills).not.toContain('settings.connectors.categories.messaging');
|
||||
});
|
||||
|
||||
it('shows a retry when the catalog fails to load', async () => {
|
||||
service.getCatalog.mockResolvedValue({ success: false });
|
||||
await render();
|
||||
|
||||
@@ -98,27 +98,28 @@ export default function Connectors() {
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
};
|
||||
|
||||
// Only categories that have something in them (connectors that need admin
|
||||
// setup are not listed, which can empty one).
|
||||
// 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 withCapability = catalog.filter((connector) =>
|
||||
capability === 'sync'
|
||||
? connector.capabilities.includes('sync')
|
||||
: capability === 'tools'
|
||||
? connector.capabilities.some((c) => c !== 'sync')
|
||||
: true,
|
||||
);
|
||||
const filters = FILTERS.filter(
|
||||
(key) =>
|
||||
key === 'all' ||
|
||||
key === filter ||
|
||||
(key === 'connected'
|
||||
? catalog.some(isConnected)
|
||||
: catalog.some((connector) => connector.category === key)),
|
||||
? withCapability.some(isConnected)
|
||||
: withCapability.some((connector) => connector.category === key)),
|
||||
);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return catalog
|
||||
.filter((connector) =>
|
||||
capability === 'sync'
|
||||
? connector.capabilities.includes('sync')
|
||||
: capability === 'tools'
|
||||
? connector.capabilities.some((c) => c !== 'sync')
|
||||
: true,
|
||||
)
|
||||
return withCapability
|
||||
.filter((connector) => {
|
||||
if (filter === 'connected') return isConnected(connector);
|
||||
if (filter !== 'all') return connector.category === filter;
|
||||
@@ -131,7 +132,7 @@ export default function Connectors() {
|
||||
connectorDescription(t, connector).toLowerCase().includes(query),
|
||||
)
|
||||
.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state]);
|
||||
}, [catalog, filter, search, t, capability]);
|
||||
}, [withCapability, filter, search, t]);
|
||||
|
||||
const open = (connector: ConnectorDefinition) => {
|
||||
if (connector.state === 'available' || connector.state === 'custom') {
|
||||
|
||||
Reference in new issue
Block a user