diff --git a/frontend/src/connectors/ConnectionDrawer.test.tsx b/frontend/src/connectors/ConnectionDrawer.test.tsx index af6c6268..6a6e680b 100644 --- a/frontend/src/connectors/ConnectionDrawer.test.tsx +++ b/frontend/src/connectors/ConnectionDrawer.test.tsx @@ -82,7 +82,7 @@ describe('ConnectionDrawer', () => { let container: HTMLDivElement; let root: Root; let store: ReturnType; - const makeStore = () => + const makeStore = (extra: Record[] = []) => configureStore({ reducer: { connectors: connectorsReducer, @@ -103,6 +103,7 @@ describe('ConnectionDrawer', () => { status: 'connected', }, { id: 'conn-gh', connector_key: 'github', status: 'connected' }, + ...extra, ], }, preference: { token: null }, @@ -125,13 +126,20 @@ describe('ConnectionDrawer', () => { container.remove(); }); - const render = async (connector: ConnectorDefinition = DRIVE) => { - store = makeStore(); + const render = async ( + connector: ConnectorDefinition = DRIVE, + options: { + extra?: Record[]; + initialConnectionId?: string; + } = {}, + ) => { + store = makeStore(options.extra); await act(async () => { root.render( @@ -145,6 +153,54 @@ describe('ConnectionDrawer', () => { document.body.querySelectorAll('button'), ).find((b) => b.textContent === text)!; + it('shows one account at a time, switched from a dropdown', async () => { + connectors.getConnection.mockImplementation(async (id: string) => ({ + success: true, + connection: + id === 'conn-2' + ? { ...DETAIL, id: 'conn-2', account_name: 'Max' } + : { ...DETAIL, account_name: 'Lena' }, + })); + await render(DRIVE, { + extra: [ + { id: 'conn-2', connector_key: 'google_drive', status: 'connected' }, + ], + initialConnectionId: 'conn-2', + }); + const text = () => document.body.textContent ?? ''; + // The account the panel was opened for, and only that one. + expect(text()).toContain('Max'); + expect(text()).not.toContain('Lena'); + + const trigger = document.body.querySelector( + '[aria-label="settings.connectors.detail.accountPicker"]', + )!; + await act(async () => { + trigger.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + button: 0, + pointerType: 'mouse', + }), + ); + }); + const other = Array.from( + document.body.querySelectorAll('[role="option"]'), + ).find((o) => o.textContent?.includes('Lena'))!; + await act(async () => other.click()); + expect(text()).toContain('Lena'); + expect(text()).not.toContain('Max'); + }); + + it('has no account dropdown with a single account', async () => { + await render(); + expect( + document.body.querySelector( + '[aria-label="settings.connectors.detail.accountPicker"]', + ), + ).toBeNull(); + }); + it('turns a tool on and off from its connection page', async () => { users.updateToolStatus.mockResolvedValue({ ok: true }); await render(); diff --git a/frontend/src/connectors/ConnectionDrawer.tsx b/frontend/src/connectors/ConnectionDrawer.tsx index 38007f81..fa15ad8d 100644 --- a/frontend/src/connectors/ConnectionDrawer.tsx +++ b/frontend/src/connectors/ConnectionDrawer.tsx @@ -26,6 +26,13 @@ import { ListRow, ListRows } from '../components/ui/list-row'; import { LoadingState } from '../components/ui/loading-state'; import { Modal, ModalActions } from '../components/ui/modal'; import { SectionHeader } from '../components/ui/section-header'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../components/ui/select'; import { SettingRow, SettingRows } from '../components/ui/setting-row'; import { Switch } from '../components/ui/switch'; import { @@ -44,7 +51,11 @@ import { ACCOUNT_NAME_MAX } from './accounts'; import { CapabilityBadges } from './ConnectorCard'; import ConnectorIcon from './ConnectorIcon'; import ConnectorSetupNotice from './ConnectorSetupNotice'; -import { loadConnectors, selectConnections } from './connectorsSlice'; +import { + connectionNeedsSignIn, + loadConnectors, + selectConnections, +} from './connectorsSlice'; import { connectorDescription, connectorName, isKeyHint } from './i18n'; import ToolPermissions from './ToolPermissions'; import type { @@ -354,6 +365,58 @@ function WritesSwitch({ ); } +/** + * Which of several accounts of a service the panel shows. The rest of the + * panel is that one account: its knowledge and its tools. + */ +function AccountPicker({ + accounts, + value, + onChange, +}: { + accounts: ConnectionDetail[]; + value: string; + onChange: (id: string) => void; +}) { + const { t } = useTranslation(); + const accountTitle = useAccountTitle(); + return ( + + ); +} + +/** + * The account to show from ``accounts``: the one picked, else one that needs + * signing in again (so a problem is not hidden behind another account), else + * the first. + */ +function shownAccount( + accounts: ConnectionDetail[], + picked: string | undefined, +): ConnectionDetail | undefined { + return ( + accounts.find((account) => account.id === picked) ?? + accounts.find(connectionNeedsSignIn) ?? + accounts[0] + ); +} + function AccountSection({ connector, detail, @@ -648,12 +711,15 @@ function AccountSection({ export default function ConnectionDrawer({ connector, parts = [], + initialConnectionId, onClose, onConnect, }: { connector: ConnectorDefinition | null; /** The same service offered another way, shown here (Jira & Confluence). */ parts?: ConnectorDefinition[]; + /** The account to show first, e.g. the one behind the tool it opened from. */ + initialConnectionId?: string; onClose: () => void; onConnect: (connector: ConnectorDefinition, options?: LaunchOptions) => void; }) { @@ -670,6 +736,16 @@ export default function ConnectionDrawer({ ); const [toRemove, setToRemove] = useState(null); const [toRename, setToRename] = useState(null); + // The account shown per service (the connector and each of its parts). + const [picked, setPicked] = useState>({}); + useEffect(() => { + setPicked({}); + }, [connector?.key, initialConnectionId]); + const pickedIn = (key: string, accounts: ConnectionDetail[]) => + picked[key] ?? + (accounts.some((account) => account.id === initialConnectionId) + ? initialConnectionId + : undefined); const keys = [connector?.key, ...parts.map((part) => part.key)]; const accountIds = connections @@ -960,7 +1036,29 @@ export default function ConnectionDrawer({ /> ) : (
- {ownDetails.map((detail) => ( + {ownDetails.length > 1 && ( + + setPicked((state) => ({ + ...state, + [connector.key]: id, + })) + } + /> + )} + {[ + shownAccount( + ownDetails, + pickedIn(connector.key, ownDetails), + )!, + ].map((detail) => ( - {partDetails.map((detail) => ( - - onConnect(part, { mode: 'sync', connectionId: d.id }) + {partDetails.length > 1 && ( + + setPicked((state) => ({ ...state, [part.key]: id })) } - onRefreshTools={refreshTools} - onToggleTool={toggleTool} - onSyncNow={syncNow} - onAddTools={addTools} - onSwitchWrites={switchWrites} /> - ))} + )} + {partDetails + .filter( + (detail) => + detail.id === + shownAccount( + partDetails, + pickedIn(part.key, partDetails), + )?.id, + ) + .map((detail) => ( + + onConnect(part, { mode: 'sync', connectionId: d.id }) + } + onRefreshTools={refreshTools} + onToggleTool={toggleTool} + onSyncNow={syncNow} + onAddTools={addTools} + onSwitchWrites={switchWrites} + /> + ))} ); })} diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 6056e006..d0a63e83 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1202,7 +1202,8 @@ "addTools": "Werkzeuge hinzufügen", "noTools": "Noch keine Werkzeuge aus dieser Verbindung.", "addToolsFailed": "Die Werkzeuge konnten nicht hinzugefügt werden. Versuche es erneut.", - "rename": "Umbenennen" + "rename": "Umbenennen", + "accountPicker": "Konto auswählen" }, "permission": { "always": "Erlauben", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 21fd392d..358effbf 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1208,7 +1208,8 @@ "addTools": "Add tools", "noTools": "No tools from this connection yet.", "addToolsFailed": "Could not add the tools. Try again.", - "rename": "Rename" + "rename": "Rename", + "accountPicker": "Choose an account" }, "permission": { "always": "Allow", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 020727c1..9d7814f4 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1202,7 +1202,8 @@ "addTools": "Añadir herramientas", "noTools": "Todavía no hay herramientas de esta conexión.", "addToolsFailed": "No se pudieron añadir las herramientas. Inténtalo de nuevo.", - "rename": "Cambiar nombre" + "rename": "Cambiar nombre", + "accountPicker": "Elegir una cuenta" }, "permission": { "always": "Permitir", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 0a64b0a7..619aeee1 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1193,7 +1193,8 @@ "addTools": "ツールを追加", "noTools": "この接続のツールはまだありません。", "addToolsFailed": "ツールを追加できませんでした。もう一度お試しください。", - "rename": "名前を変更" + "rename": "名前を変更", + "accountPicker": "アカウントを選択" }, "permission": { "always": "許可", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 8af5e3cd..8a2f253d 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1258,7 +1258,8 @@ "addTools": "Добавить инструменты", "noTools": "У этого подключения пока нет инструментов.", "addToolsFailed": "Не удалось добавить инструменты. Попробуйте ещё раз.", - "rename": "Переименовать" + "rename": "Переименовать", + "accountPicker": "Выберите аккаунт" }, "permission": { "always": "Разрешать", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 010e180b..64027ef1 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1193,7 +1193,8 @@ "addTools": "新增工具", "noTools": "此連線還沒有工具。", "addToolsFailed": "無法新增工具。請再試一次。", - "rename": "重新命名" + "rename": "重新命名", + "accountPicker": "選擇帳號" }, "permission": { "always": "允許", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index f1b09845..1c72982d 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1193,7 +1193,8 @@ "addTools": "添加工具", "noTools": "此连接还没有工具。", "addToolsFailed": "无法添加工具。请重试。", - "rename": "重命名" + "rename": "重命名", + "accountPicker": "选择账号" }, "permission": { "always": "允许", diff --git a/frontend/src/settings/Tools.test.tsx b/frontend/src/settings/Tools.test.tsx index 1dc466c3..50b633b1 100644 --- a/frontend/src/settings/Tools.test.tsx +++ b/frontend/src/settings/Tools.test.tsx @@ -106,8 +106,16 @@ vi.mock('../api/services/connectorsService', () => ({ // The connector panel itself is tested on its own; here it only has to open // for the right service. vi.mock('../connectors/ConnectionDrawer', () => ({ - default: ({ connector }: { connector: { key: string } | null }) => - connector ?
{connector.key}
: null, + default: ({ + connector, + initialConnectionId, + }: { + connector: { key: string } | null; + initialConnectionId?: string; + }) => + connector ? ( +
{`${connector.key}:${initialConnectionId}`}
+ ) : null, })); vi.mock('../connectors/useConnectorLauncher', () => ({ default: () => ({ launch: vi.fn(), modals: null }), @@ -194,7 +202,7 @@ describe('Tools page', () => { ); expect( document.body.querySelector('[data-testid="drawer"]')?.textContent, - ).toBe('telegram'); + ).toBe('telegram:conn-1'); // Stays on the Tools page. expect(document.body.querySelector('[data-testid="where"]')).toBeNull(); }); diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index 4749a1c4..54b966f4 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -76,9 +76,10 @@ export default function Tools() { null, ); // The connection panel, opened here for a connected tool's service. - const [managed, setManaged] = React.useState( - null, - ); + const [managed, setManaged] = React.useState<{ + connector: ConnectorDefinition; + connectionId: string; + } | null>(null); const [mcpStatuses, setMcpStatuses] = React.useState<{ [toolId: string]: string; }>({}); @@ -262,7 +263,7 @@ export default function Tools() { const connection = connectionOf(tool); if (connection) { const connector = catalog.find((c) => c.key === connection.connector_key); - if (connector) setManaged(connector); + if (connector) setManaged({ connector, connectionId: connection.id }); else navigate( `/settings/connectors?connector=${encodeURIComponent(connection.connector_key)}`, @@ -607,7 +608,8 @@ export default function Tools() { }} /> { setManaged(null); getUserTools();