diff --git a/docs/design/evidence/task-754/after-1440-dark.png b/docs/design/evidence/task-754/after-1440-dark.png new file mode 100644 index 0000000000..e52601d54f Binary files /dev/null and b/docs/design/evidence/task-754/after-1440-dark.png differ diff --git a/docs/design/evidence/task-754/after-1440-light-drawer.png b/docs/design/evidence/task-754/after-1440-light-drawer.png new file mode 100644 index 0000000000..2b4a249cba Binary files /dev/null and b/docs/design/evidence/task-754/after-1440-light-drawer.png differ diff --git a/docs/design/evidence/task-754/after-1440-light.png b/docs/design/evidence/task-754/after-1440-light.png new file mode 100644 index 0000000000..6ebfdc2b34 Binary files /dev/null and b/docs/design/evidence/task-754/after-1440-light.png differ diff --git a/docs/design/evidence/task-754/after-390-dark.png b/docs/design/evidence/task-754/after-390-dark.png new file mode 100644 index 0000000000..88dbc9d3ee Binary files /dev/null and b/docs/design/evidence/task-754/after-390-dark.png differ diff --git a/docs/design/evidence/task-754/after-390-light.png b/docs/design/evidence/task-754/after-390-light.png new file mode 100644 index 0000000000..fd54acc5a3 Binary files /dev/null and b/docs/design/evidence/task-754/after-390-light.png differ diff --git a/docs/design/evidence/task-754/artboard-1440-dark.png b/docs/design/evidence/task-754/artboard-1440-dark.png new file mode 100644 index 0000000000..d908972c7d Binary files /dev/null and b/docs/design/evidence/task-754/artboard-1440-dark.png differ diff --git a/docs/design/evidence/task-754/artboard-1440-light.png b/docs/design/evidence/task-754/artboard-1440-light.png new file mode 100644 index 0000000000..51aaceb28d Binary files /dev/null and b/docs/design/evidence/task-754/artboard-1440-light.png differ diff --git a/docs/design/evidence/task-754/artboard-390-dark.png b/docs/design/evidence/task-754/artboard-390-dark.png new file mode 100644 index 0000000000..03901a2cb7 Binary files /dev/null and b/docs/design/evidence/task-754/artboard-390-dark.png differ diff --git a/docs/design/evidence/task-754/artboard-390-light.png b/docs/design/evidence/task-754/artboard-390-light.png new file mode 100644 index 0000000000..d3d84e9719 Binary files /dev/null and b/docs/design/evidence/task-754/artboard-390-light.png differ diff --git a/docs/design/evidence/task-754/before-1440-dark.png b/docs/design/evidence/task-754/before-1440-dark.png new file mode 100644 index 0000000000..fb2a2769ea Binary files /dev/null and b/docs/design/evidence/task-754/before-1440-dark.png differ diff --git a/docs/design/evidence/task-754/before-1440-light.png b/docs/design/evidence/task-754/before-1440-light.png new file mode 100644 index 0000000000..6b0e8dd5fe Binary files /dev/null and b/docs/design/evidence/task-754/before-1440-light.png differ diff --git a/docs/design/evidence/task-754/before-390-dark.png b/docs/design/evidence/task-754/before-390-dark.png new file mode 100644 index 0000000000..2cd61e01cd Binary files /dev/null and b/docs/design/evidence/task-754/before-390-dark.png differ diff --git a/docs/design/evidence/task-754/before-390-light.png b/docs/design/evidence/task-754/before-390-light.png new file mode 100644 index 0000000000..a763dac5b5 Binary files /dev/null and b/docs/design/evidence/task-754/before-390-light.png differ diff --git a/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx index 5c0d3ccc33..efd240e6fd 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx @@ -267,12 +267,89 @@ describe('Connectors page: the tile surface', () => { renderPage(); await findTile('GitHub'); - const row = await screen.findByRole('button', { name: /View Unknown connector/ }); - fireEvent.click(row); + fireEvent.click(await findTile('Unknown connector')); expect(await findDrawer(/Unknown connector/)).toBeInTheDocument(); expect(await screen.findByRole('button', { name: /^Revoke/ })).toBeInTheDocument(); }); + // TASK-754 (D226 #3): the grant sat under the tiles as a full-width card with + // its own title size, a status dot and a lone gear. Under D223 it takes the + // grid's own shape: a tile beside GitHub's, opening the same drawer. + it('a live grant whose entry is gone is a tile in the Tools grid, not a row with a gear', async () => { + const iso = (ms) => new Date(Date.now() + ms).toISOString(); + const orphan = { + grantId: 'grant_orphan', installationId: 'inst-x', target: { kind: 'pod', id: 'p1' }, tools: ['retired.read'], + writeMode: 'read', budget: null, effectiveAudience: [], expiresAt: iso(86_400_000), revokedAt: null, revokedBy: null, + parentGrantId: null, rootGrantId: null, createdAt: iso(-3_600_000), grantedBy: 'u1', + }; + mockCatalog([tool('github', 'GitHub')], { grants: [orphan] }); + renderPage(); + + const github = await findTile('GitHub'); + const tile = await findTile('Unknown connector'); + expect(tile.parentElement).toBe(github.parentElement); + expect(within(tile).getByRole('img', { name: 'Granted' })).toBeInTheDocument(); + // A live tile is counted as on; GitHub, with no grant, is still available. + expect(document.querySelector('.v2-cx-head__meta')).toHaveTextContent('1 on · 1 available'); + const tools = screen.getByRole('region', { name: 'Tools' }); + expect(tools.querySelector('.v2-connector-row')).toBeNull(); + expect(tools.querySelector('.v2-connector-row__action--icon')).toBeNull(); + expect(tile).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(tile); + expect(await findDrawer(/Unknown connector/)).toBeInTheDocument(); + expect(tileNode('Unknown connector')).toHaveAttribute('aria-pressed', 'true'); + }); + + // TASK-754 review: two grants on one connector are one tile, counted once, + // and its drawer lists both by pod so they can be told apart. + it('two live grants on one gone connector are one tile whose drawer lists both', async () => { + const iso = (ms) => new Date(Date.now() + ms).toISOString(); + const orphan = (grantId, podId) => ({ + grantId, installationId: `inst-${grantId}`, target: { kind: 'pod', id: podId }, tools: ['atlassian.getJiraIssue'], + connector: { id: 'atlassian', label: 'Atlassian' }, + writeMode: 'read', budget: null, effectiveAudience: [], expiresAt: iso(86_400_000), revokedAt: null, revokedBy: null, + parentGrantId: null, rootGrantId: null, createdAt: iso(-3_600_000), grantedBy: 'u1', + }); + const twoPods = [...pods, { _id: 'p2', name: 'Ops Room', type: 'chat', members: [] }]; + mockApi({ installables: [tool('github', 'GitHub')], pods: twoPods, grants: [orphan('g-a1', 'p1'), orphan('g-a2', 'p2')] }); + renderPage(); + + await findTile('GitHub'); + await findTile('Atlassian'); + expect(document.querySelectorAll('button.v2-cx-tile[title="Atlassian"]')).toHaveLength(1); + expect(document.querySelector('.v2-cx-head__meta')).toHaveTextContent('1 on · 1 available'); + fireEvent.click(tileNode('Atlassian')); + const drawer = await findDrawer(/Atlassian/); + expect(within(drawer).getByRole('button', { name: /View Atlassian in Rewire Live Demo/ })).toBeInTheDocument(); + expect(within(drawer).getByRole('button', { name: /View Atlassian in Ops Room/ })).toBeInTheDocument(); + expect(tileNode('Atlassian')).toHaveAttribute('aria-pressed', 'true'); + }); + + // A search that hides an open group closes its drawer for good: clearing the + // search must not reopen a drawer nobody clicked (CodeRabbit on #3207). + it('a search that hides an open orphan group drops the selection, so clearing it reopens nothing', async () => { + const iso = (ms) => new Date(Date.now() + ms).toISOString(); + const orphan = (grantId, podId) => ({ + grantId, installationId: `inst-${grantId}`, target: { kind: 'pod', id: podId }, tools: ['atlassian.getJiraIssue'], + connector: { id: 'atlassian', label: 'Atlassian' }, + writeMode: 'read', budget: null, effectiveAudience: [], expiresAt: iso(86_400_000), revokedAt: null, revokedBy: null, + parentGrantId: null, rootGrantId: null, createdAt: iso(-3_600_000), grantedBy: 'u1', + }); + const twoPods = [...pods, { _id: 'p2', name: 'Ops Room', type: 'chat', members: [] }]; + mockApi({ installables: [tool('github', 'GitHub')], pods: twoPods, grants: [orphan('g-a1', 'p1'), orphan('g-a2', 'p2')] }); + renderPage(); + + fireEvent.click(await findTile('Atlassian')); + await findDrawer(/Atlassian/); + const search = screen.getByRole('searchbox', { name: 'Search' }); + fireEvent.change(search, { target: { value: 'github' } }); + await waitFor(() => expect(document.querySelector('section.v2-cx-drawer[aria-label="Atlassian"]')).toBeNull()); + fireEvent.change(search, { target: { value: '' } }); + await findTile('Atlassian'); + expect(document.querySelector('section.v2-cx-drawer[aria-label="Atlassian"]')).toBeNull(); + expect(tileNode('Atlassian')).toHaveAttribute('aria-pressed', 'false'); + }); + it('names a grant whose entry is gone by the connector the server reads from its tools (TASK-753)', async () => { const iso = (ms) => new Date(Date.now() + ms).toISOString(); // A hidden entry is absent from the catalogue, so no row covers this grant; @@ -287,11 +364,11 @@ describe('Connectors page: the tile surface', () => { renderPage(); await findTile('GitHub'); - const row = await screen.findByRole('button', { name: /View Atlassian/ }); - expect(row.querySelector('.v2-connector-row__glyph svg')).not.toBeNull(); - expect(row).toHaveTextContent('read in testing'); - expect(screen.queryByRole('button', { name: /View Unknown connector/ })).toBeNull(); - fireEvent.click(row); + const tile = await findTile('Atlassian'); + expect(tile.querySelector('.v2-cx-tile__logo svg')).not.toBeNull(); + expect(tile.querySelector('.v2-cx-tile__beta')).toHaveAttribute('title', 'read in testing'); + expect(tileNode('Unknown connector')).toBeNull(); + fireEvent.click(tile); expect(await findDrawer(/Atlassian/)).toBeInTheDocument(); expect(await screen.findByRole('button', { name: /^Revoke/ })).toBeInTheDocument(); }); diff --git a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx index c3c06372d5..1322e1aab8 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx @@ -939,9 +939,9 @@ describe('Connectors page: tools', () => { const tools = await screen.findByRole('region', { name: 'Tools' }); await settle(); - const rows = tools.querySelectorAll('.v2-connector-row'); - expect(rows).toHaveLength(1); - expect(rows[0]).not.toHaveClass('v2-connector-row--dead'); + // An orphan grant is a tile since TASK-754: one tile, for the live grant. + expect(tools.querySelectorAll('.v2-cx-tile')).toHaveLength(1); + expect(tools.querySelector('.v2-connector-row')).toBeNull(); }); /** diff --git a/frontend/src/v2/connectors/ConnectorsPage.tsx b/frontend/src/v2/connectors/ConnectorsPage.tsx index baf811dca5..d3f403c16d 100644 --- a/frontend/src/v2/connectors/ConnectorsPage.tsx +++ b/frontend/src/v2/connectors/ConnectorsPage.tsx @@ -10,7 +10,7 @@ import { catalogDescription } from '../utils/connectorCatalogDescription'; import { ADD_PLATFORMS, TYPE_LABELS } from './channelModel'; import type { ListItem } from './channelModel'; import { entryCovers, entryIsGranted, isDead } from './toolModel'; -import type { ToolCatalogEntry } from './toolModel'; +import type { ToolCatalogEntry, ToolGrant } from './toolModel'; import { useChannelConnectors } from './useChannelConnectors'; import { useToolConnectors } from './useToolConnectors'; import { useToolPanels } from './ToolPanels'; @@ -52,6 +52,8 @@ const ConnectorsPage: React.FC = () => { const [search, setSearch] = useState(''); const [pop, setPop] = useState(null); const [toolKey, setToolKey] = useState(null); + // The gone connector whose tile opened its list of grants (TASK-754). + const [orphanKey, setOrphanKey] = useState(null); const [offOpen, setOffOpen] = useState({ channels: false, tools: false }); const phone = usePhone(); const [searchOpen, setSearchOpen] = useState(false); @@ -110,13 +112,26 @@ const ConnectorsPage: React.FC = () => { && !isDead(grant, m.now) && (!q || m.toolLabel(grant).toLowerCase().includes(q)) )); + // One tile per gone connector, not per grant: two grants on one connector + // would otherwise be two tiles with one name (TASK-754). + const orphanKeyOf = (grant: ToolGrant): string => grant.connector?.id || grant.tools[0]?.split('.')[0] || grant.grantId; + const orphanGroups = new Map(); + orphanGrants.forEach((grant) => { + const key = orphanKeyOf(grant); + orphanGroups.set(key, [...(orphanGroups.get(key) || []), grant]); + }); + const orphanGroup = orphanKey ? orphanGroups.get(orphanKey) || null : null; + // A group the search (or a revoke) took away closes for good, so clearing the + // search never reopens a drawer nobody clicked. + const orphanKeyGone = Boolean(orphanKey) && !orphanGroup; + useEffect(() => { if (orphanKeyGone) setOrphanKey(null); }, [orphanKeyGone]); const toolHasDrawer = (entry: ToolCatalogEntry): boolean => grantsOf(entry).length > 0 || hiddenOf(entry).length > 0; // ---- what is open ---- const drawerItem = c.items.find((item) => item.key === c.selectedKey) || null; const channelDrawerOpen = c.adding || drawerItem !== null; const toolEntry = toolKey ? m.catalog.find((entry) => entry.installableId === toolKey) || null : null; - const toolDrawerOpen = !channelDrawerOpen && Boolean(m.draft || m.githubAppSetup || toolEntry || m.selected); + const toolDrawerOpen = !channelDrawerOpen && Boolean(m.draft || m.githubAppSetup || toolEntry || m.selected || orphanGroup); // Opening the phone search puts the cursor in the box. useEffect(() => { if (searchOpen) searchRef.current?.focus(); }, [searchOpen]); @@ -150,6 +165,7 @@ const ConnectorsPage: React.FC = () => { const closeTools = useCallback(() => { setToolKey(null); + setOrphanKey(null); m.setSelectedId(null); m.setDraft(null); m.setGithubAppSetup(null); @@ -182,6 +198,7 @@ const ConnectorsPage: React.FC = () => { m.setDraft(null); m.setGithubAppSetup(null); m.setSelectedId(null); + setOrphanKey(null); if (toolHasDrawer(entry)) { setPop(null); setToolKey(entry.installableId); @@ -191,6 +208,24 @@ const ConnectorsPage: React.FC = () => { setPop({ key: `tool:${entry.installableId}`, anchor: event.currentTarget }); }; + // A gone connector has no entry drawer. One grant opens its own details; + // several open their list, as an entry's tile does. + const openOrphan = (key: string, group: ToolGrant[]) => { + closeChannels(); + setPop(null); + setToolKey(null); + m.setDraft(null); + m.setGithubAppSetup(null); + m.setConfirmRevoke(null); + if (group.length === 1) { + setOrphanKey(null); + m.setSelectedId(group[0].grantId); + return; + } + m.setSelectedId(null); + setOrphanKey(key); + }; + const openAdd = () => { closeTools(); setPop(null); @@ -253,6 +288,23 @@ const ConnectorsPage: React.FC = () => { /> ); + // A live grant whose entry is gone is still access (TASK-752), so it keeps a + // tile in the Tools grid, in the grid's own shape (TASK-754, D223). The + // server's `connector` names it when the catalogue cannot (TASK-753). + const renderOrphanTile = ([key, group]: [string, ToolGrant[]]) => ( + openOrphan(key, group)} + /> + ); + // ---- the reveal ---- const renderPopover = () => { if (!pop) return null; @@ -391,9 +443,17 @@ const ConnectorsPage: React.FC = () => { ); } if (!toolEntry) { + if (orphanKey && orphanGroup) { + return ( + +
{orphanGroup.map(tp.renderRow)}
+ {m.selected && tp.renderAside(m.selected)} +
+ ); + } if (!m.selected) return null; return ( - + {tp.renderAside(m.selected)} ); @@ -433,7 +493,12 @@ const ConnectorsPage: React.FC = () => { const offeredCount = c.items.filter((item) => !channelInactive(item)).length + m.catalog.filter((entry) => !toolOff(entry)).length; const availableCount = offeredCount - onCount; - const meta = t('connectors.meta', { defaultValue: '{{on}} on · {{available}} available', on: onCount, available: Math.max(0, availableCount) }); + // An orphan grant's tile is live too (TASK-754), so it counts as on. It was + // never offered, so it leaves `available` alone. + const orphanOn = new Set(grants + .filter((grant) => !m.catalog.some((entry) => entryCovers(entry, grant)) && !isDead(grant, m.now)) + .map(orphanKeyOf)).size; + const meta = t('connectors.meta', { defaultValue: '{{on}} on · {{available}} available', on: onCount + orphanOn, available: Math.max(0, availableCount) }); const showChannels = filter !== 'tools'; const showTools = filter !== 'channels'; @@ -529,6 +594,7 @@ const ConnectorsPage: React.FC = () => {
{showChannels && liveChannels.map((item) => renderChannelTile(item))} {showTools && liveTools.map((entry) => renderToolTile(entry))} + {showTools && [...orphanGroups].map(renderOrphanTile)} {phoneOffOpen && showChannels && offChannels.map((item) => renderChannelTile(item, true))} {phoneOffOpen && showTools && offTools.map((entry) => renderToolTile(entry, true))} {showChannels && availableProviders.length > 0 && !q && ( @@ -550,7 +616,6 @@ const ConnectorsPage: React.FC = () => { {t('connectors.offCount', { defaultValue: '+{{n}} off', n: offTotal })} )} - {showTools && orphanGrants.length > 0 &&
{orphanGrants.map(tp.renderRow)}
} {showTools && phoneOffOpen && orphanHidden.length > 0 && (
{orphanHidden.map(tp.renderHiddenConnection)}
)} @@ -586,9 +651,9 @@ const ConnectorsPage: React.FC = () => {
{liveTools.map((entry) => renderToolTile(entry))} + {[...orphanGroups].map(renderOrphanTile)} {offOpen.tools && offTools.map((entry) => renderToolTile(entry, true))}
- {orphanGrants.length > 0 &&
{orphanGrants.map(tp.renderRow)}
} {offOpen.tools && orphanHidden.length > 0 && (
{orphanHidden.map(tp.renderHiddenConnection)}
)}