Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions frontend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,13 @@ module.exports = {
// Connectors + Tools (TASK-164) — the channels list and the grants list
// rendered English defaults under zh-CN: neither file was in this manifest,
// so translationKeys.test.ts never saw the keys they were using.
'src/v2/components/V2ConnectorsPage.tsx',
'src/v2/components/V2ConnectorTools.tsx',
// Moved into src/v2/connectors by the tile-grid rebuild (TASK-733).
'src/v2/connectors/ChannelPanel.tsx',
'src/v2/connectors/ConnectorDrawer.tsx',
'src/v2/connectors/ConnectorPopover.tsx',
'src/v2/connectors/ConnectorTile.tsx',
'src/v2/connectors/ConnectorsPage.tsx',
'src/v2/connectors/ToolPanels.tsx',
'src/v2/utils/localizeRelativeTime.ts',
],
rules: {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/i18n/__tests__/translationKeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('i18n migration manifest', () => {
);

// Catalog description maps are locale-only: English renders the server's
// description, and a locale without a map falls back to it. See V2ConnectorsPage.
// description, and a locale without a map falls back to it. See ConnectorsPage.
const LOCALE_ONLY_PREFIX = 'connectors.catalogDescriptions.';

it('keeps every registered language base key path identical', () => {
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import path from 'path';
// be decided from this catalog: ``{{agents}}`` is a count in `yourTeam.head.meta_other`
// and a name list joined with ' and ' in `inspector.workspace.nothingWorking_other`.
// A first cut of the sweep declared `agents` a numeral by name and closed
// 「Scout 可以使用」 in the Tools panel; `V2ConnectorTools.test.tsx` caught it. So the
// 「Scout 可以使用」 in the Tools panel; `ConnectorsPage.tools.test.tsx` caught it. So the
// default is opaque, the numeral names are declared here, and the coverage test
// below makes a new interpolation fail rather than inherit whichever silence looks
// convenient.
Expand Down Expand Up @@ -57,7 +57,14 @@ const OPAQUE_PLACEHOLDERS = [
// The exception runs the safe way: a name that is usually opaque and is a number
// here, named by key. `agents` is a count in yourTeam.head.meta (counts.agents) and
// a name list in the inspector, so it cannot be declared for the catalog as a whole.
const NUMERAL_BY_KEY: Array<[string, string]> = [['yourTeam.head.meta_other', 'agents']];
// `on`, `available` and `n` are counts in the Connectors page strings (the tile
// grid meta line and the "+N off" toggle), but opaque elsewhere.
const NUMERAL_BY_KEY: Array<[string, string]> = [
['yourTeam.head.meta_other', 'agents'],
['connectors.meta', 'on'],
['connectors.meta', 'available'],
['connectors.offCount', 'n'],
];

// A digit is this rule's numeral only when it stands alone, and that is a question
// about the TOKEN the digit sits inside, so it has two halves. `Apache-2.0 许可` and
Expand Down
25 changes: 14 additions & 11 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2272,12 +2272,16 @@
}
},
"connectors": {
"about": "About connectors",
"counts": {
"connected_one": "{{count}} connected",
"connected_other": "{{count}} connected",
"needsYou_one": "{{count}} needs you",
"needsYou_other": "{{count}} need you"
"search": "Search",
"filterLabel": "Show",
"channelsGroup": "Channels",
"addChannelTile": "Channel",
"meta": "{{on}} on · {{available}} available",
"offCount": "+{{n}} off",
"filter": {
"all": "All",
"channels": "Channels",
"tools": "Tools"
},
"inactiveFold": "inactive · {{n}}",
"loading": "Loading connectors…",
Expand Down Expand Up @@ -2327,6 +2331,7 @@
"availableTelegram": "one message",
"cancel": "Cancel",
"channelDetails": "Channel details",
"closeDetails": "Close",
"channelSees": "What the channel sees",
"channels": "Your channels",
"codeError": "Could not create a new code.",
Expand All @@ -2335,7 +2340,6 @@
"connectChannel": "Connect a channel",
"copied": "Copied",
"copyCommand": "Copy command",
"description": "Connect a channel to a pod. Messages arrive there as you; choose what reaches the channel.",
"errorLine": "The connection dropped.",
"errorReconnect": "reconnect to resume",
"gateLead": "Lead agent for {{pod}}",
Expand Down Expand Up @@ -2480,6 +2484,9 @@
"loadFailed": "Could not load artifacts."
},
"tools": {
"granted": "Granted",
"beta": "beta",
"adminOnce": "An admin installs this once",
"emptyAudienceNotice": "Select at least one agent to grant access.",
"makePodInviteOnly": "Make this pod invite-only",
"podInviteOnlyNow": "This pod is invite-only. Select Grant to continue.",
Expand Down Expand Up @@ -2528,7 +2535,6 @@
"grantDetails": "Grant details",
"grantError": "Could not grant it.",
"grantedByOn": "Granted by {{member}} {{rel}}.",
"grantedCount": "{{count}} granted",
"grantedOn": "Granted {{rel}}.",
"grantedToPod": "granted to <b>{{pod}}</b>",
"grantedToPodBy": "granted to <b>{{pod}}</b> by <b>{{member}}</b>",
Expand All @@ -2537,14 +2543,12 @@
"installationId": "installation ID",
"installingGitHubApp": "Installing…",
"keep": "Keep it",
"loading": "Loading tools…",
"manage": "Manage",
"mayUse": "{{agents}} may use it",
"mode": "what it may do",
"modeRead": "read",
"modeWrite": "read and write",
"modeWriteConfirm": "read and write, ask first",
"moreCount": "{{count}} more",
"existingConnection": "existing connection",
"noLongerOffered": "This connector is no longer offered here.",
"removeConnection": "Remove connection",
Expand Down Expand Up @@ -2584,7 +2588,6 @@
"revokeOld": "Revoke the old grant",
"revokedByLine": "revoked by {{member}} {{rel}}",
"revokedLine": "revoked {{rel}}",
"search": "Search tools",
"segAll": "All",
"segGranted": "Granted",
"segNotYet": "Not yet",
Expand Down
25 changes: 14 additions & 11 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2262,12 +2262,16 @@
}
},
"connectors": {
"about": "关于连接器",
"counts": {
"connected_one": "已连接{{count}}个频道",
"connected_other": "已连接{{count}}个频道",
"needsYou_one": "{{count}}项需要你处理",
"needsYou_other": "{{count}}项需要你处理"
"search": "搜索",
"filterLabel": "显示",
"channelsGroup": "频道",
"addChannelTile": "频道",
"meta": "已开启{{on}}个 · 可用{{available}}个",
"offCount": "另有{{n}}个未启用",
"filter": {
"all": "全部",
"channels": "频道",
"tools": "工具"
},
"inactiveFold": "非活跃 · {{n}}",
"loading": "正在加载连接器…",
Expand Down Expand Up @@ -2317,6 +2321,7 @@
"availableTelegram": "发一条消息",
"cancel": "取消",
"channelDetails": "频道详情",
"closeDetails": "关闭",
"channelSees": "频道可见内容",
"channels": "你的频道",
"codeError": "无法生成新的启用码。",
Expand All @@ -2325,7 +2330,6 @@
"connectChannel": "连接频道",
"copied": "已复制",
"copyCommand": "复制命令",
"description": "将频道连接到 Pod。消息会以你的身份进入 Pod;你可以选择哪些内容回传到频道。",
"errorLine": "连接已断开。",
"errorReconnect": "重新连接以恢复",
"gateLead": "{{pod}} 的主导智能体",
Expand Down Expand Up @@ -2483,6 +2487,9 @@
"loadFailed": "无法加载产物。"
},
"tools": {
"granted": "已授权",
"beta": "测试版",
"adminOnce": "需管理员安装一次",
"emptyAudienceNotice": "请至少选择一个智能体以授予访问权限。",
"makePodInviteOnly": "将此 Pod 设为仅限邀请",
"podInviteOnlyNow": "此 Pod 现已设为仅限邀请。再次选择“授权”继续。",
Expand Down Expand Up @@ -2531,7 +2538,6 @@
"grantDetails": "授权详情",
"grantError": "无法授权。",
"grantedByOn": "{{rel}}由 {{member}} 授权。",
"grantedCount": "已授权{{count}}个",
"grantedOn": "{{rel}}授权。",
"grantedToPod": "授权给 <b>{{pod}}</b>",
"grantedToPodBy": "由 <b>{{member}}</b> 授权给 <b>{{pod}}</b>",
Expand All @@ -2540,14 +2546,12 @@
"installationId": "安装 ID",
"installingGitHubApp": "安装中…",
"keep": "保留",
"loading": "正在加载工具…",
"manage": "管理",
"mayUse": "{{agents}} 可以使用",
"mode": "可执行的操作",
"modeRead": "读取",
"modeWrite": "读取和写入",
"modeWriteConfirm": "读取和写入,写入前需确认",
"moreCount": "另有{{count}}个",
"existingConnection": "现有连接",
"noLongerOffered": "此连接器已不在本实例提供。",
"removeConnection": "移除连接",
Expand Down Expand Up @@ -2587,7 +2591,6 @@
"revokeOld": "撤销旧授权",
"revokedByLine": "{{rel}}由 {{member}} 撤销",
"revokedLine": "{{rel}}撤销",
"search": "搜索工具",
"segAll": "全部",
"segGranted": "已授权",
"segNotYet": "尚未授权",
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/v2/V2App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const V2AgentSettingsPage = React.lazy(() => import('./agents/V2AgentSettingsPag
const UserProfile = React.lazy(() => import('../components/UserProfile'));
const V2AgentBYO = React.lazy(() => import('./components/V2AgentBYO'));
const V2ConnectPage = React.lazy(() => import('./components/V2ConnectPage'));
const V2ConnectorsPage = React.lazy(() => import('./components/V2ConnectorsPage'));
const ConnectorsPage = React.lazy(() => import('./connectors/ConnectorsPage'));
const V2PodBoard = React.lazy(() => import('./components/V2PodBoard'));
const V2RunTrace = React.lazy(() => import('./components/V2RunTrace'));
const V2ActivityPage = React.lazy(() => import('./components/V2ActivityPage'));
Expand Down Expand Up @@ -301,10 +301,11 @@ const V2App: React.FC = () => {
path="connectors"
element={feature(
'Connectors',
<V2ConnectorsPage />,
<ConnectorsPage />,
false,
// The Signal artboard owns this page's display-sized head.
false,
'v2-feature--connectors',
)}
/>
<Route
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/v2/__testkit__/axiosMock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// The axios stand-in the Connectors suites share. It imports nothing, so a suite's
// `jest.mock('axios', () => require('../__testkit__/axiosMock').axiosFactory())`
// cannot loop back through the modules that import axios.
export const axiosFactory = () => {
const mock = {
get: jest.fn(),
post: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
defaults: { baseURL: '', headers: { common: {} } },
interceptors: {
request: { use: jest.fn(), eject: jest.fn() },
response: { use: jest.fn(), eject: jest.fn() },
},
};
return { __esModule: true, default: mock, ...mock };
};
141 changes: 141 additions & 0 deletions frontend/src/v2/__testkit__/connectorsKit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// @ts-nocheck
// Shared harness for the Connectors page suites (ConnectorsPage.channels / .tools /
// .layout). It is not a test file: it lives outside `__tests__` so jest does not
// collect it. A suite still declares the `jest.mock('axios', ...)` itself (babel-jest
// only hoists the call in the file that makes it), with `axiosMock.ts` as the factory.
import React from 'react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import ConnectorsPage from '../connectors/ConnectorsPage';
import { AuthContext } from '../../context/AuthContext';

export const axios = jest.requireMock('axios').default;

export const authValue = {
currentUser: { _id: 'u1', username: 'sam' },
user: { _id: 'u1', username: 'sam' },
token: 'user-jwt',
loading: false,
error: null,
isAuthenticated: true,
register: jest.fn(),
login: jest.fn(),
logout: jest.fn(),
updateProfile: jest.fn(),
};

export const asUser = (user) => ({ ...authValue, currentUser: user, user });
export const adminAuth = (over = {}) => asUser({ ...authValue.user, role: 'admin', ...over });

export const emptyCounts = { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 };

/**
* One GET router for every read the page makes. Anything not given answers the
* empty shape of its route, so a suite states only what it is about.
*
* connectors /api/integrations/user/all
* installables /api/installables (channels and tools share it); `null` rejects
* the read, and leaving it out answers `[]` (no catalogue)
* hidden hiddenToolConnections on the same response
* pods /api/pods (an Error rejects, 'pending' never settles)
* grants /api/grants
* seats { [podId]: seat[] } for /api/registry/pods/:id/agents
* trails { [grantId]: trail } for /api/grants/:id/calls
* route (url, config) => Promise | undefined, consulted first
*/
export const mockApi = ({
connectors = [],
installables,
hidden,
pods = [],
grants = [],
seats = {},
trails = {},
route,
} = {}) => {
axios.get.mockImplementation((url, config) => {
const routed = route ? route(url, config) : undefined;
if (routed !== undefined) return routed;
if (url === '/api/integrations/user/all') return Promise.resolve({ data: connectors });
if (url === '/api/installables') {
if (installables === null) return Promise.reject(new Error('catalogue unavailable'));
if (installables === undefined) return Promise.resolve({ data: [] });
return Promise.resolve({ data: { installables, ...(hidden ? { hiddenToolConnections: hidden } : {}) } });
}
if (url === '/api/pods') {
if (pods === 'pending') return new Promise(() => {});
if (pods instanceof Error) return Promise.reject(pods);
return Promise.resolve({ data: pods });
}
if (url === '/api/grants') return Promise.resolve({ data: { grants } });
const seatRoute = /^\/api\/registry\/pods\/([^/]+)\/agents$/.exec(url);
if (seatRoute) return Promise.resolve({ data: { agents: seats[seatRoute[1]] || [] } });
const callsRoute = /^\/api\/grants\/([^/]+)\/calls$/.exec(url);
if (callsRoute) {
return Promise.resolve({ data: trails[callsRoute[1]] || { grantId: callsRoute[1], calls: [], counts: emptyCounts } });
}
return Promise.resolve({ data: [] });
});
};

export const renderPage = ({ auth = authValue, entries } = {}) => render(
<AuthContext.Provider value={auth}>
<MemoryRouter initialEntries={entries}>
<ConnectorsPage />
</MemoryRouter>
</AuthContext.Provider>,
);

export const renderWithRoutes = (routes, { auth = authValue, entries = ['/v2/connectors'] } = {}) => render(
<AuthContext.Provider value={auth}>
<MemoryRouter initialEntries={entries}>{routes}</MemoryRouter>
</AuthContext.Provider>,
);

// ---- the tile surface ----
// A tile's accessible name is its dot's words plus its label, so a tile is found by
// the title it carries (the connector's label), never by role name.
export const tileNode = (name) => document.querySelector(`button.v2-cx-tile[title="${name}"]`);
export const findTile = async (name) => {
await waitFor(() => expect(tileNode(name)).not.toBeNull());
return tileNode(name);
};
export const clickTile = async (name) => {
const node = await findTile(name);
fireEvent.click(node);
return node;
};
export const findDrawer = (name) => screen.findByRole('region', { name });
export const findPopover = (name) => screen.findByRole('dialog', { name });

/** Opens the drawer of a connected tile and returns it. */
export const openDrawer = async (name) => {
await clickTile(name);
return findDrawer(name);
};
/** Opens the popover of an unconnected tile and returns it. */
export const openPopover = async (name) => {
await clickTile(name);
return findPopover(name);
};

/** Opens a tool's drawer, then the grant row's selection to read its aside. */
export const openGrant = async (toolName, rowName) => {
const drawer = await openDrawer(toolName);
fireEvent.click(within(drawer).getByRole('button', { name: rowName }));
const aside = await within(drawer).findByRole('complementary', { name: 'Grant details' });
return { drawer, aside };
};

/** The "+N off" button of a group ("Channels" or "Tools"), clicked open. */
export const expandOff = async (group) => {
const section = await screen.findByRole('region', { name: group });
fireEvent.click(await within(section).findByRole('button', { name: /^\+\d+ off$/ }));
return section;
};

/** Waits for the page's first read to settle (the loading line is gone). */
export const pageReady = async () => {
await screen.findByRole('heading', { name: 'Connectors' });
await waitFor(() => expect(screen.queryByText('Loading connectors…')).toBeNull());
};
Loading
Loading