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
67 changes: 67 additions & 0 deletions frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,70 @@ describe('Connectors page: the tile surface', () => {
expect(screen.queryByRole('region', { name: 'Discord' })).toBeNull();
});
});

describe('Connectors page: phone layout (d215 frame, 390)', () => {
const realMatchMedia = window.matchMedia;
beforeEach(() => {
window.matchMedia = jest.fn().mockImplementation(() => ({
matches: true, addEventListener: jest.fn(), removeEventListener: jest.fn(),
}));
});
afterEach(() => { window.matchMedia = realMatchMedia; });

it('is one grid with no group titles, the filter under the head and search behind an icon', async () => {
mockCatalog([channel('telegram', 'Telegram'), liveSlack, tool('github', 'GitHub'), tool('linear', 'Linear')]);
renderPage();

await findTile('GitHub');
expect(document.querySelectorAll('.v2-cx-grid')).toHaveLength(1);
expect(document.querySelector('.v2-cx-group__title')).toBeNull();
expect(document.querySelector('.v2-cx-head__meta')).toBeNull();
// the filter sits in the body under the 52px head, not in it
expect(document.querySelector('.v2-cx-head .v2-cx-seg')).toBeNull();
expect(document.querySelector('.v2-cx-body .v2-cx-seg')).not.toBeNull();
// channels first, tools next, the add tile last
const names = Array.from(document.querySelectorAll('.v2-cx-tile')).map((node) => node.getAttribute('title'));
expect(names).toEqual(['Telegram', 'Slack', 'GitHub', 'Linear', 'Channel']);
expect(names[names.length - 1]).toBe('Channel');

expect(screen.queryByRole('searchbox')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Search' }));
expect(screen.getByRole('searchbox')).toBeInTheDocument();
});

it('folds every inactive tile under one +N off', async () => {
mockCatalog([
channel('discord', 'Discord', { available: false }),
tool('github', 'GitHub'),
tool('linear', 'Linear', { available: false, connections: [] }),
]);
renderPage();

await findTile('GitHub');
const toggles = document.querySelectorAll('.v2-cx-off');
expect(toggles).toHaveLength(1);
expect(toggles[0]).toHaveTextContent('+2 off');
expect(document.querySelector('.v2-cx-tile[title="Discord"]')).toBeNull();
fireEvent.click(toggles[0]);
expect(await findTile('Discord')).toBeInTheDocument();
expect(await findTile('Linear')).toBeInTheDocument();
});

it('counts +N off for the chosen filter only, and keeps the search button honest', async () => {
mockCatalog([channel('telegram', 'Telegram'), tool('github', 'GitHub'), tool('linear', 'Linear', { available: false, connections: [] })]);
renderPage();

await findTile('GitHub');
expect(document.querySelector('.v2-cx-off')).toHaveTextContent('+1 off');
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
expect(document.querySelector('.v2-cx-off')).toBeNull();

const searchButton = screen.getByRole('button', { name: 'Search' });
expect(searchButton).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(searchButton);
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'tele' } });
fireEvent.click(searchButton);
expect(searchButton).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('searchbox')).toBeInTheDocument();
});
});
6 changes: 6 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2513,6 +2513,12 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(cx).toMatch(/\.v2-feature--connectors > \.v2-feature__body \{[^}]*padding: 0;/);
});

it('the phone page is a 52px head, then a 12px-padded body of 104px tiles', () => {
expect(mediaBlock(640)).toMatch(/\.v2-cx-head \{[^}]*height: 52px/);
expect(mediaBlock(640)).toMatch(/\.v2-cx-body \{[^}]*padding: 12px/);
expect(mediaBlock(640)).toMatch(/\.v2-cx-tile \{[^}]*height: 104px/);
});

it('the grid is six columns, three on a phone', () => {
expect(baseBody('.v2-cx-grid')).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))');
expect(mediaBlock(640)).toMatch(/\.v2-cx-grid \{[^}]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/);
Expand Down
108 changes: 89 additions & 19 deletions frontend/src/v2/connectors/ConnectorsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { ToolCatalogEntry } from './toolModel';
import { useChannelConnectors } from './useChannelConnectors';
import { useToolConnectors } from './useToolConnectors';
import { useToolPanels } from './ToolPanels';
import { usePhone } from './usePhone';
import ChannelPanel from './ChannelPanel';
import ConnectorDrawer from './ConnectorDrawer';
import ConnectorLogo from './ConnectorLogo';
Expand Down Expand Up @@ -52,6 +53,9 @@ const ConnectorsPage: React.FC = () => {
const [pop, setPop] = useState<Pop | null>(null);
const [toolKey, setToolKey] = useState<string | null>(null);
const [offOpen, setOffOpen] = useState({ channels: false, tools: false });
const phone = usePhone();
const [searchOpen, setSearchOpen] = useState(false);
const searchRef = useRef<HTMLInputElement | null>(null);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const tileRefs = useRef<Record<string, HTMLButtonElement | null>>({});

Expand Down Expand Up @@ -111,6 +115,12 @@ const ConnectorsPage: React.FC = () => {
const toolEntry = toolKey ? m.catalog.find((entry) => entry.installableId === toolKey) || null : null;
const toolDrawerOpen = !channelDrawerOpen && Boolean(m.draft || m.githubAppSetup || toolEntry || m.selected);

// Opening the phone search puts the cursor in the box.
useEffect(() => { if (searchOpen) searchRef.current?.focus(); }, [searchOpen]);

// Crossing the phone breakpoint remounts every tile, so a reveal's anchor is gone.
useEffect(() => { setPop(null); }, [phone]);

// A drawer and a reveal never show together, and a reveal that opened a drawer
// stays closed when the drawer does.
useEffect(() => {
Expand Down Expand Up @@ -452,39 +462,99 @@ const ConnectorsPage: React.FC = () => {
</button>
) : null);

const filterSeg = (
<div className="v2-cx-seg" role="group" aria-label={t('connectors.filterLabel', { defaultValue: 'Show' })}>
{FILTERS.map((value) => (
<button key={value} type="button" aria-pressed={filter === value} onClick={() => setFilter(value)}>
{t(`connectors.filter.${value}`, { defaultValue: value === 'all' ? 'All' : value === 'channels' ? 'Channels' : 'Tools' })}
</button>
))}
</div>
);
const searchBox = (
<label className="v2-cx-search">
<SearchGlyph />
<input
ref={searchRef}
type="search"
value={search}
placeholder={t('connectors.search', { defaultValue: 'Search' })}
aria-label={t('connectors.search', { defaultValue: 'Search' })}
onChange={(event) => onSearch(event.target.value)}
/>
</label>
);
// Phone: one grid, channels then tools, the add tile last, one "+N off" under it.
const offTotal = (showChannels ? offChannels.length : 0) + (showTools ? offTools.length + orphanHidden.length : 0);
const phoneOffOpen = offOpen.channels || offOpen.tools;

return (
<div className="v2-cx">
<header className="v2-cx-head">
<h1>{t('connectors.title', { defaultValue: 'Connectors' })}</h1>
{!c.loading && <span className="v2-cx-head__meta">{meta}</span>}
{!phone && !c.loading && <span className="v2-cx-head__meta">{meta}</span>}
<span className="v2-cx-head__sp" />
<label className="v2-cx-search">
<SearchGlyph />
<input
type="search"
value={search}
placeholder={t('connectors.search', { defaultValue: 'Search' })}
{phone ? (
<button
type="button"
className="v2-cx-searchbtn"
aria-label={t('connectors.search', { defaultValue: 'Search' })}
onChange={(event) => onSearch(event.target.value)}
/>
</label>
<div className="v2-cx-seg" role="group" aria-label={t('connectors.filterLabel', { defaultValue: 'Show' })}>
{FILTERS.map((value) => (
<button key={value} type="button" aria-pressed={filter === value} onClick={() => setFilter(value)}>
{t(`connectors.filter.${value}`, { defaultValue: value === 'all' ? 'All' : value === 'channels' ? 'Channels' : 'Tools' })}
</button>
))}
</div>
aria-expanded={searchOpen || Boolean(q)}
onClick={() => setSearchOpen((open) => !open)}
>
<SearchGlyph />
</button>
) : (
<>
{searchBox}
{filterSeg}
</>
)}
</header>

{c.loading && <div className="v2-connectors__loading">{t('connectors.loading', { defaultValue: 'Loading connectors…' })}</div>}

{!c.loading && (
<div className="v2-cx-body" ref={setContainer}>
<div className="v2-cx-inner">
{phone && filterSeg}
{phone && (searchOpen || q) && searchBox}
{!channelDrawerOpen && !toolDrawerOpen && noticeBlock}

{showChannels && (
{phone && (
<>
<div className="v2-cx-grid">
{showChannels && liveChannels.map((item) => renderChannelTile(item))}
{showTools && liveTools.map((entry) => renderToolTile(entry))}
{phoneOffOpen && showChannels && offChannels.map((item) => renderChannelTile(item, true))}
{phoneOffOpen && showTools && offTools.map((entry) => renderToolTile(entry, true))}
{showChannels && availableProviders.length > 0 && !q && (
<ConnectorTile
type="add"
name={t('connectors.addChannelTile', { defaultValue: 'Channel' })}
add
onClick={openAdd}
/>
)}
</div>
{offTotal > 0 && (
<button
type="button"
className="v2-cx-off v2-cx-off--phone"
aria-expanded={phoneOffOpen}
onClick={() => setOffOpen({ channels: !phoneOffOpen, tools: !phoneOffOpen })}
>
{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>
)}
</>
)}

{!phone && showChannels && (
<section className="v2-cx-group" aria-label={t('connectors.channelsGroup', { defaultValue: 'Channels' })}>
<h2 className="v2-cx-group__title">
{t('connectors.channelsGroup', { defaultValue: 'Channels' })}
Expand All @@ -505,7 +575,7 @@ const ConnectorsPage: React.FC = () => {
</section>
)}

{showTools && (
{!phone && showTools && (
<section className="v2-cx-group" aria-label={t('tools.title', { defaultValue: 'Tools' })}>
<h2 className="v2-cx-group__title">
{t('tools.title', { defaultValue: 'Tools' })}
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/v2/connectors/connectors.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
.v2-cx-head h1 { margin: 0; font: 800 24px/1 var(--v2-font-display, inherit); letter-spacing: -0.03em; white-space: nowrap; }
.v2-cx-head__meta { font: 500 11.5px/1 var(--v2-font-mono, ui-monospace, monospace); color: var(--v2-text-muted); white-space: nowrap; }
.v2-cx-head__sp { flex: 1; }
.v2-cx-searchbtn { all: unset; box-sizing: border-box; display: none; cursor: pointer; place-items: center; width: 44px; height: 44px; margin-right: -8px; border-radius: 8px; color: var(--v2-text-secondary); }
.v2-cx-searchbtn svg { width: 18px; height: 18px; }
.v2-cx-searchbtn:focus-visible { box-shadow: 0 0 0 2px var(--v2-accent); }
.v2-cx-search { flex: none; width: 260px; height: 34px; border-radius: 10px; background: var(--v2-bg-subtle); box-shadow: inset 0 0 0 1px var(--v2-border-soft); display: flex; align-items: center; gap: 7px; padding: 0 10px; color: var(--v2-text-placeholder); min-width: 0; }
.v2-cx-search svg { flex: none; width: 15px; height: 15px; }
.v2-cx-search input { all: unset; flex: 1; min-width: 0; font-size: 13px; color: var(--v2-text-primary); }
Expand Down Expand Up @@ -89,10 +92,14 @@

/* phone: three to a row, a tab bar below (the shell's), the reveal as a sheet */
@media (max-width: 640px) {
.v2-cx-head { min-height: 52px; padding: 10px 10px 0 12px; gap: 8px; flex-wrap: wrap; }
.v2-cx-head { min-height: 52px; height: 52px; padding: 0 12px; gap: 8px; border-bottom: 0; }
.v2-cx-head h1 { font-size: 21px; }
.v2-cx-search { width: auto; flex: 1 1 100%; order: 3; margin: 0 0 10px; }
.v2-cx-searchbtn { display: grid; }
.v2-cx-body { padding: 12px; }
.v2-cx-body .v2-cx-seg { display: flex; width: 100%; box-sizing: border-box; margin: 0 0 10px; }
.v2-cx-body .v2-cx-seg button { flex: 1 1 0; }
.v2-cx-body .v2-cx-search { width: auto; margin: 0 0 10px; }
.v2-cx-off--phone { display: block; margin: 10px 0 0 4px; font: 500 11.5px/1 var(--v2-font-mono, ui-monospace, monospace); }
.v2-cx-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.v2-cx-tile { height: 104px; }
}
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/v2/connectors/usePhone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// True at phone width (the page's 640px breakpoint). The phone page is laid out
// differently from the wide one (one merged grid, a search icon, the filter below
// the head), so it is a render decision, not only CSS.
import { useEffect, useState } from 'react';

const QUERY = '(max-width: 640px)';

const read = (): boolean => (
typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(QUERY).matches
);

export const usePhone = (): boolean => {
const [phone, setPhone] = useState<boolean>(read);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
const list = window.matchMedia(QUERY);
const onChange = () => setPhone(list.matches);
onChange();
// Safari before 14 only has the old addListener pair.
if (list.addEventListener) list.addEventListener('change', onChange); else list.addListener(onChange);
return () => {
if (list.removeEventListener) list.removeEventListener('change', onChange); else list.removeListener(onChange);
};
}, []);
return phone;
};
Loading