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
Binary file added docs/design/evidence/task-377-ended-1440.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design/evidence/task-377-ended-390.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design/evidence/task-377-offers-1440.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions docs/design/evidence/task-377-tools-offers.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
TASK-377 Connectors Tools grouping — 2026-10-23

Local Chromium/Vite fixture rendered the production V2ConnectorTools component and v2.css with a non-admin user and five catalog offers (GitHub, Linear, Atlassian, Airtable, Sentry). API reads were mocked only to supply the two requested states; this is not a signed-in production screenshot.

task-377-offers-1440.png: zero grants, five offer rows, no inactive fold.
task-377-ended-1440.png: eight ended grants, five visible offer rows, closed inactive · 8 fold.
task-377-ended-390.png: same ended state at 390px; five offers and the fold remain visible. document.body.scrollWidth = 390, viewport = 390.

The fixture source was removed after capture. The fold tests in V2ConnectorTools.fold.test.tsx supply the reproducible data and assertions.
54 changes: 50 additions & 4 deletions frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,56 @@ const grant = (id: string, revokedAt: string | null) => ({
});
const catalog = [{ installableId: 'github', list: 'tools', label: 'GitHub', description: 'Issues', available: true,
tools: [{ name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false }], connections: [] }];
const offers = ['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry'].map((label) => ({
...catalog[0], installableId: label.toLowerCase(), label,
tools: [{ name: `${label.toLowerCase()}.list_issues`, requiredWriteMode: 'read', irreversible: false }],
}));

const mockTools = (entries: typeof catalog, grants: ReturnType<typeof grant>[]) => {
axios.get.mockImplementation((url: string) => {
if (url === '/api/installables') return Promise.resolve({ data: { installables: entries } });
if (url === '/api/pods/p1/grants') return Promise.resolve({ data: { grants } });
if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } });
if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } });
return Promise.reject(new Error(`unmocked ${url}`));
});
};

const renderTools = () => render(<AuthContext.Provider value={auth}><MemoryRouter>
<V2ConnectorTools pods={[{ _id: 'p1', name: 'Launch pod', members: [{ _id: 'u1', username: 'sam' }] }]} />
</MemoryRouter></AuthContext.Provider>);

beforeAll(async () => { await i18nReady; await i18n.changeLanguage('en'); });

test('five never-granted catalogue offers are visible with no inactive fold', async () => {
mockTools(offers, []);
const { container } = renderTools();

await waitFor(() => expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(5));
expect(screen.getByText('0 granted · 5 more')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /inactive ·/ })).not.toBeInTheDocument();
expect([...container.querySelectorAll('.v2-connector-row__name')].map((row) => row.textContent))
.toEqual(['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry']);
expect(screen.queryByText('Nothing matches.')).not.toBeInTheDocument();
});

test('eight ended grants fold below five never-granted offers', async () => {
const grants = [
...Array.from({ length: 4 }, (_, i) => grant(`revoked-${i}`, yesterday)),
...Array.from({ length: 4 }, (_, i) => ({ ...grant(`expired-${i}`, null), expiresAt: yesterday })),
];
mockTools(offers, grants);
const { container } = renderTools();

const fold = await screen.findByRole('button', { name: 'inactive · 8' });
expect(fold).toHaveAttribute('aria-expanded', 'false');
expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(5);
expect([...container.querySelectorAll('.v2-connector-row__name')].map((row) => row.textContent))
.toEqual(['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry']);
fireEvent.click(fold);
expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(13);
});

test('two live grants stay visible and nine revoked or expired grants start in one closed fold', async () => {
const grants = [grant('live-1', null), grant('live-2', null),
...Array.from({ length: 4 }, (_, i) => grant(`revoked-${i}`, yesterday)),
Expand All @@ -58,7 +105,7 @@ test('two live grants stay visible and nine revoked or expired grants start in o
expect(fold).toHaveAttribute('aria-expanded', 'true');
});

test('search counts only matching inactive grants, catalogue entries and connections', async () => {
test('search keeps a matching catalogue offer visible and folds only ended connections', async () => {
const entries = [catalog[0], { ...catalog[0], installableId: 'linear', connectionType: 'hosted-mcp', entryId: 'linear', label: 'Linear', description: 'Linear issues',
tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }] }];
axios.get.mockImplementation((url: string) => {
Expand All @@ -74,11 +121,10 @@ test('search counts only matching inactive grants, catalogue entries and connect
const { container } = render(<AuthContext.Provider value={auth}><MemoryRouter>
<V2ConnectorTools pods={[{ _id: 'p1', name: 'Launch pod', members: [{ _id: 'u1', username: 'sam' }] }]} />
</MemoryRouter></AuthContext.Provider>);
expect(await screen.findByRole('button', { name: 'inactive · 3' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'inactive · 2' })).toBeInTheDocument();

fireEvent.change(screen.getByRole('searchbox', { name: 'Search tools' }), { target: { value: 'Linear' } });
const fold = screen.getByRole('button', { name: 'inactive · 1' });
fireEvent.click(fold);
expect(screen.queryByRole('button', { name: /inactive ·/ })).not.toBeInTheDocument();
expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(1);
expect(container.querySelector('.v2-connector-row')).toHaveTextContent('Linear');
expect(container).not.toHaveTextContent('Atlassian');
Expand Down
3 changes: 0 additions & 3 deletions frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,6 @@ describe('V2ConnectorsPage', () => {
}], [hostedLinearCatalogEntry()]);
renderPage();

fireEvent.click(await screen.findByRole('button', { name: 'inactive · 1' }));
const reviewLink = await screen.findByRole('link', { name: "Open Linear's connected apps" });
expect(reviewLink).toHaveAttribute('href', revokePage);
expect(reviewLink).toHaveAttribute('target', '_blank');
Expand Down Expand Up @@ -767,7 +766,6 @@ describe('V2ConnectorsPage', () => {
try {
renderPage();

fireEvent.click(await screen.findByRole('button', { name: '非活跃 · 1' }));
const reviewLink = await screen.findByRole('link', { name: '打开 Linear 的已连接应用' });
expect(reviewLink).toHaveAttribute('href', revokePage);
const notice = screen.getByRole('status');
Expand All @@ -793,7 +791,6 @@ describe('V2ConnectorsPage', () => {
}], [hostedLinearCatalogEntry()]);
renderPage();

fireEvent.click(await screen.findByRole('button', { name: 'inactive · 1' }));
const linearDescription = await screen.findByText('Read Linear issues and projects.');
expect(linearDescription.closest('.v2-connector-row')).toBeInTheDocument();
await waitFor(() => expect(window.location.search).toBe(''));
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/v2/components/V2ConnectorTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,16 +403,16 @@ const V2ConnectorTools: React.FC<Props> = ({ pods, manualRevokeNotice = null })
return toolLabel(grant).toLowerCase().includes(q) || grant.tools.some((tool) => tool.toLowerCase().includes(q));
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [grants, q, catalog]);
// Catalogue entries without a live grant join the inactive fold. `now` is
// a dep so expiry moves a grant into the fold without a reload.
// Catalogue entries without a live grant are offers. `now` is a dep so an
// expired grant becomes inactive and its catalogue entry becomes an offer.
const notYet = useMemo(() => catalog.filter((entry) => (
!entryIsGranted(entry, grants || [], now)
&& (!q || entry.label.toLowerCase().includes(q) || entry.tools.some((tool) => tool.name.toLowerCase().includes(q)))
)), [catalog, grants, now, q]);
const liveRows = rows.filter((grant) => !isDead(grant, now));
const inactiveRows = rows.filter((grant) => isDead(grant, now));
const matchedHiddenConnections = hiddenConnections.filter((connection) => !q || connection.label.toLowerCase().includes(q));
const inactiveCount = inactiveRows.length + notYet.length + matchedHiddenConnections.length;
const inactiveCount = inactiveRows.length + matchedHiddenConnections.length;
// Grants, not entries: a person may hold two live grants on one tool.
const grantedCount = (grants || []).filter((grant) => !isDead(grant, now)).length;
// Entries, from the same predicate the Not yet list filters on, so the header
Expand Down Expand Up @@ -1089,12 +1089,12 @@ const V2ConnectorTools: React.FC<Props> = ({ pods, manualRevokeNotice = null })
{removedHiddenNotice && <p className="v2-connector-aside__note" role="status">{t('tools.removedReviewProvider', { defaultValue: 'Connection removed. Review Commonly access at {{label}}.', label: removedHiddenNotice.label })} <a href={removedHiddenNotice.revokePage} target="_blank" rel="noopener noreferrer">{t('tools.openProviderSettings', { defaultValue: 'Open provider settings' })}</a></p>}
<div className="v2-connectors__rows">
{liveRows.map(renderRow)}
{notYet.map(renderNotYet)}
<V2InactiveFold count={inactiveCount}>
{inactiveRows.map(renderRow)}
{notYet.map(renderNotYet)}
{matchedHiddenConnections.map(renderHiddenConnection)}
</V2InactiveFold>
{liveRows.length === 0 && inactiveCount === 0 && (
{liveRows.length === 0 && notYet.length === 0 && inactiveCount === 0 && (
<p className="v2-tools__empty">{t('tools.nothingMatches', { defaultValue: 'Nothing matches.' })}</p>
)}
</div>
Expand Down
Loading