diff --git a/docs/design/evidence/task-377-ended-1440.png b/docs/design/evidence/task-377-ended-1440.png new file mode 100644 index 0000000000..5fc6d7d5a5 Binary files /dev/null and b/docs/design/evidence/task-377-ended-1440.png differ diff --git a/docs/design/evidence/task-377-ended-390.png b/docs/design/evidence/task-377-ended-390.png new file mode 100644 index 0000000000..7d4924b828 Binary files /dev/null and b/docs/design/evidence/task-377-ended-390.png differ diff --git a/docs/design/evidence/task-377-offers-1440.png b/docs/design/evidence/task-377-offers-1440.png new file mode 100644 index 0000000000..828b71281f Binary files /dev/null and b/docs/design/evidence/task-377-offers-1440.png differ diff --git a/docs/design/evidence/task-377-tools-offers.txt b/docs/design/evidence/task-377-tools-offers.txt new file mode 100644 index 0000000000..b8f0df50a5 --- /dev/null +++ b/docs/design/evidence/task-377-tools-offers.txt @@ -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. diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx index 78064494b4..04a59ae0a9 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx @@ -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[]) => { + 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( + +); 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)), @@ -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) => { @@ -74,11 +121,10 @@ test('search counts only matching inactive grants, catalogue entries and connect const { container } = render( ); - 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'); diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index d568aa0136..5f8a853f06 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -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'); @@ -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'); @@ -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('')); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index 135cd10693..a547e3cc6e 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -403,8 +403,8 @@ const V2ConnectorTools: React.FC = ({ 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))) @@ -412,7 +412,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) 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 @@ -1089,12 +1089,12 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) {removedHiddenNotice &&

{t('tools.removedReviewProvider', { defaultValue: 'Connection removed. Review Commonly access at {{label}}.', label: removedHiddenNotice.label })} {t('tools.openProviderSettings', { defaultValue: 'Open provider settings' })}

}
{liveRows.map(renderRow)} + {notYet.map(renderNotYet)} {inactiveRows.map(renderRow)} - {notYet.map(renderNotYet)} {matchedHiddenConnections.map(renderHiddenConnection)} - {liveRows.length === 0 && inactiveCount === 0 && ( + {liveRows.length === 0 && notYet.length === 0 && inactiveCount === 0 && (

{t('tools.nothingMatches', { defaultValue: 'Nothing matches.' })}

)}