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-754/after-1440-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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-754/after-390-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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-754/before-390-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
91 changes: 84 additions & 7 deletions frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
});
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

/**
Expand Down
77 changes: 71 additions & 6 deletions frontend/src/v2/connectors/ConnectorsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,6 +52,8 @@ const ConnectorsPage: React.FC = () => {
const [search, setSearch] = useState('');
const [pop, setPop] = useState<Pop | null>(null);
const [toolKey, setToolKey] = useState<string | null>(null);
// The gone connector whose tile opened its list of grants (TASK-754).
const [orphanKey, setOrphanKey] = useState<string | null>(null);
const [offOpen, setOffOpen] = useState({ channels: false, tools: false });
const phone = usePhone();
const [searchOpen, setSearchOpen] = useState(false);
Expand Down Expand Up @@ -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<string, ToolGrant[]>();
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]);
Expand Down Expand Up @@ -150,6 +165,7 @@ const ConnectorsPage: React.FC = () => {

const closeTools = useCallback(() => {
setToolKey(null);
setOrphanKey(null);
m.setSelectedId(null);
m.setDraft(null);
m.setGithubAppSetup(null);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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[]]) => (
<ConnectorTile
key={`orphan:${key}`}
type={key}
name={m.toolLabel(group[0])}
dot={{ kind: 'live', label: t('tools.granted', { defaultValue: 'Granted' }) }}
beta={key === 'atlassian'
? { word: t('tools.beta', { defaultValue: 'beta' }), title: t('tools.readInTesting', { defaultValue: 'read in testing' }) }
: undefined}
selected={orphanKey === key || (group.length === 1 && m.selectedId === group[0].grantId)}
onClick={() => openOrphan(key, group)}
/>
);

// ---- the reveal ----
const renderPopover = () => {
if (!pop) return null;
Expand Down Expand Up @@ -391,9 +443,17 @@ const ConnectorsPage: React.FC = () => {
);
}
if (!toolEntry) {
if (orphanKey && orphanGroup) {
return (
<ConnectorDrawer notices={noticeBlock} type={orphanKey} title={m.toolLabel(orphanGroup[0])} onClose={closeTools}>
<div className="v2-cx-list">{orphanGroup.map(tp.renderRow)}</div>
{m.selected && tp.renderAside(m.selected)}
</ConnectorDrawer>
);
}
if (!m.selected) return null;
return (
<ConnectorDrawer notices={noticeBlock} type={m.selected.tools[0]?.split('.')[0] || 'tools'} title={m.toolLabel(m.selected)} onClose={closeTools}>
<ConnectorDrawer notices={noticeBlock} type={orphanKeyOf(m.selected)} title={m.toolLabel(m.selected)} onClose={closeTools}>
{tp.renderAside(m.selected)}
</ConnectorDrawer>
);
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -529,6 +594,7 @@ const ConnectorsPage: React.FC = () => {
<div className="v2-cx-grid">
{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 && (
Expand All @@ -550,7 +616,6 @@ const ConnectorsPage: React.FC = () => {
{t('connectors.offCount', { defaultValue: '+{{n}} off', n: offTotal })}
</button>
)}
{showTools && orphanGrants.length > 0 && <div className="v2-cx-list">{orphanGrants.map(tp.renderRow)}</div>}
{showTools && phoneOffOpen && orphanHidden.length > 0 && (
<div className="v2-cx-list">{orphanHidden.map(tp.renderHiddenConnection)}</div>
)}
Expand Down Expand Up @@ -586,9 +651,9 @@ const ConnectorsPage: React.FC = () => {
</h2>
<div className="v2-cx-grid">
{liveTools.map((entry) => renderToolTile(entry))}
{[...orphanGroups].map(renderOrphanTile)}
{offOpen.tools && offTools.map((entry) => renderToolTile(entry, true))}
</div>
{orphanGrants.length > 0 && <div className="v2-cx-list">{orphanGrants.map(tp.renderRow)}</div>}
{offOpen.tools && orphanHidden.length > 0 && (
<div className="v2-cx-list">{orphanHidden.map(tp.renderHiddenConnection)}</div>
)}
Expand Down
Loading