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
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.
8 changes: 3 additions & 5 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1971,6 +1971,7 @@
"needsYou_one": "{{count}} needs you",
"needsYou_other": "{{count}} need you"
},
"inactiveFold": "inactive · {{n}}",
"loading": "Loading connectors…",
"loadError": "Could not load connectors.",
"createError": "Could not create the connector.",
Expand All @@ -1994,8 +1995,6 @@
"footnote": "You get a one-time code to send to the bot. More platforms are on the way.",
"add": "Add",
"noPod": "no pod",
"notEnabledKicker": "not enabled",
"notYetKicker": "not yet",
"kickerAttention": "attention",
"kickerMirror": "mirror",
"kickerOff": "relay off",
Expand Down Expand Up @@ -2065,13 +2064,12 @@
"newCode": "New code",
"nextStep": "Next step",
"noPods": "Join a pod first.",
"notConnectable": "Not connectable yet.",
"notConnectable": "Unavailable here.",
"notConnected": "not connected",
"notEnabled": "Not enabled on this instance.",
"notEnabled": "Not offered on this instance.",
"notLinkedDetail": "your messages have nowhere to go",
"notLinkedLine": "{{title}} · not linked to a pod",
"notRelaying": "not relaying",
"notYetLine": "Not yet. Tell us which channel you need and we build it next.",
"nothingSent": "nothing was sent",
"pausedEyebrow": "Paused",
"pausedLine": "Paused by an administrator.",
Expand Down
8 changes: 3 additions & 5 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1963,6 +1963,7 @@
"needsYou_one": "{{count}}项需要你处理",
"needsYou_other": "{{count}}项需要你处理"
},
"inactiveFold": "非活跃 · {{n}}",
"loading": "正在加载连接器…",
"loadError": "无法加载连接器。",
"createError": "无法创建连接器。",
Expand All @@ -1986,8 +1987,6 @@
"footnote": "你会获得一个一次性代码,发送给机器人即可。更多平台即将支持。",
"add": "添加",
"noPod": "无 Pod",
"notEnabledKicker": "未启用",
"notYetKicker": "暂未支持",
"kickerAttention": "提醒",
"kickerMirror": "镜像",
"kickerOff": "中继已关",
Expand Down Expand Up @@ -2057,13 +2056,12 @@
"newCode": "新启用码",
"nextStep": "下一步",
"noPods": "请先加入一个 Pod。",
"notConnectable": "暂不可连接。",
"notConnectable": "此处不可用。",
"notConnected": "未连接",
"notEnabled": "此实例尚未启用。",
"notEnabled": "此实例不提供。",
"notLinkedDetail": "你的消息还没有去处",
"notLinkedLine": "{{title}} · 未连接到 Pod",
"notRelaying": "未转发",
"notYetLine": "还没有。告诉我们你需要哪个频道,我们下一个就做。",
"nothingSent": "尚未发送任何内容",
"pausedEyebrow": "已暂停",
"pausedLine": "管理员已暂停。",
Expand Down
102 changes: 55 additions & 47 deletions frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,21 +213,18 @@ describe('V2ConnectorsPage', () => {
}
});

it('TASK-162: the not-yet channel names are separate elements, with a separator that only shows at ≤760', async () => {
// TASK-338: dead rows sit under the shared "inactive · N" fold, collapsed.
const openInactiveFold = async () => {
fireEvent.click(await screen.findByRole('button', { name: /^inactive · \d+$/ }));
};

it('TASK-338: the unbuilt-provider placeholder is gone, and no not-yet copy renders', async () => {
mockGets([]);
const { container } = renderPage();
await waitFor(() => expect(container.querySelector('.v2-connector-row--not-yet')).not.toBeNull());
const names = container.querySelector('.v2-connector-row--not-yet .v2-connector-row__names');
expect(names).not.toBeNull();
// Separate elements, not one joined string: above 760 they stack in the name
// track, so a joined string would be a single 140px-wide line that runs into
// the details column. TASK-024 left this row one name — Discord is a built
// connector and now arrives through the catalog — so the LIST is what is
// asserted and the separator count follows from its length. Nothing renders a
// `__name-sep` until a second unbuilt provider joins WhatsApp; the rule that
// shows it at ≤760 stays pinned in v2-layout-invariants.test.ts.
expect(Array.from(names!.querySelectorAll('.v2-connector-row__name-item')).map((item) => item.textContent)).toEqual(['WhatsApp']);
expect(names!.querySelectorAll('.v2-connector-row__name-sep')).toHaveLength(0);
await waitFor(() => expect(container.querySelector('.v2-connectors__rows')).not.toBeNull());
expect(container.querySelector('.v2-connector-row__names')).toBeNull();
expect(screen.queryByText(/not yet/i)).toBeNull();
expect(screen.queryByText(/Tell us which channel/)).toBeNull();
});

it('TASK-131: relative ages advance in place, and a returning tab re-reads, without a reload', async () => {
Expand Down Expand Up @@ -261,17 +258,11 @@ describe('V2ConnectorsPage', () => {
expect(screen.getByText('Send /commonly-enable in your Telegram chat.')).toBeInTheDocument();
expect(screen.getByText('Code expires in 5 min')).toBeInTheDocument();
expect(screen.getByText('Rewire crew · linked to Ops')).toBeInTheDocument();
const notYetNames = container.querySelector('.v2-connector-row--not-yet .v2-connector-row__names');
// TASK-024: this row is "we have not built it", so it lists only providers
// with no manifest at all. Discord is a built connector (routes/discord.ts;
// discordProvider.ts) and reaches the page through the catalog — available, or
// the not-enabled row when the instance lacks its credentials — so naming it
// here asserted something false. Scoped to this row deliberately: a plain
// queryByText would also fail if a later fixture adds Discord as a catalog row.
expect(notYetNames?.textContent).toBe('WhatsApp');
// TASK-338: the unbuilt-provider placeholder row is gone from the list.
expect(container.querySelector('.v2-connector-row__names')).toBeNull();
expect(screen.getByText('/commonly-enable abc1 23')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Copy command' })).toBeInTheDocument();
expect(container.querySelectorAll('.v2-connector-row__glyph')).toHaveLength(3);
expect(container.querySelectorAll('.v2-connector-row__glyph')).toHaveLength(2);
});

// TASK-156. A linked Slack stores its workspace in `teamName`
Expand Down Expand Up @@ -508,11 +499,12 @@ describe('V2ConnectorsPage', () => {
));
});

it('removes the retired SOON tiles and keeps unavailable providers in the Not yet row', async () => {
it('removes the retired SOON tiles and the not-yet placeholder copy', async () => {
mockGets([]);
renderPage();

expect(await screen.findByText(/Not yet\. Tell us which channel/)).toBeInTheDocument();
await screen.findByRole('button', { name: /^inactive · \d+$/ }).catch(() => null);
expect(screen.queryByText(/Tell us which channel/)).toBeNull();
expect(screen.queryByText('SOON')).toBeNull();
});

Expand Down Expand Up @@ -1101,26 +1093,39 @@ describe('V2ConnectorsPage', () => {
return container.querySelector('svg path')?.getAttribute('d') || null;
};

it('TASK-338: dead rows collapse under one inactive fold below the live rows', async () => {
mockCatalog([
entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }),
entry({
installableId: 'telegram',
label: 'Telegram',
available: true,
installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] },
integration: liveIntegration({ _id: 'i-tg', installationId: 'install-telegram-u1', type: 'telegram' }),
}),
]);
const { container } = renderPage();
const fold = await screen.findByRole('button', { name: 'inactive · 1' });
expect(fold).toHaveAttribute('aria-expanded', 'false');
const rows = container.querySelector('.v2-connectors__rows') as HTMLElement;
expect(rows.firstElementChild).toHaveTextContent('Telegram');
expect(screen.queryByText('Not offered on this instance.')).toBeNull();
fireEvent.click(fold);
expect(screen.getByText('Not offered on this instance.')).toBeInTheDocument();
});

it('describes Discord only through the catalog, never as a provider we have not built', async () => {
mockCatalog([
entry({ installableId: 'discord', label: 'Discord', available: false, unavailableReason: 'not_configured' }),
]);
renderPage();

const notEnabled = (await screen.findByText('Not enabled on this instance.')).closest('.v2-connector-row');
await openInactiveFold();
const notEnabled = (await screen.findByText('Not offered on this instance.')).closest('.v2-connector-row');
expect(notEnabled).toHaveClass('v2-connector-row--not-enabled');

const notYet = (await screen.findByText(/Not yet\. Tell us which channel/)).closest('.v2-connector-row') as HTMLElement;
expect(notYet).toHaveClass('v2-connector-row--not-yet');
expect(notYet.textContent).toContain('WhatsApp');
expect(notYet.textContent).not.toContain('Discord');

// The glyph tracks the label: the row is about WhatsApp now, and the two
// glyphs differ, so this cannot pass by comparing a value to itself.
const whatsapp = glyphPath('whatsapp');
expect(whatsapp).not.toBeNull();
expect(whatsapp).not.toBe(glyphPath('discord'));
expect(notYet.querySelector('svg path')?.getAttribute('d')).toBe(whatsapp);
expect(notEnabled?.textContent).toContain('Discord');
expect(notEnabled?.querySelector('svg path')?.getAttribute('d')).toBe(glyphPath('discord'));
expect(screen.queryByText(/Tell us which channel/)).toBeNull();
});

it('offers a rostered provider with Add, and an unmapped one inherits no onboarding sentence', async () => {
Expand All @@ -1140,9 +1145,7 @@ describe('V2ConnectorsPage', () => {
const slack = screen.getByText('Slack').closest('.v2-connector-row') as HTMLElement;
expect(slack.textContent).toContain('one click in your workspace');

const notYet = screen.getByText(/Not yet\. Tell us which channel/).closest('.v2-connector-row') as HTMLElement;
expect(notYet.textContent).toContain('WhatsApp');
expect(notYet.textContent).not.toContain('Discord');
expect(screen.queryByText(/Tell us which channel/)).toBeNull();
});

it('reads a usable-but-unrostered provider as not connectable, with no Add anywhere', async () => {
Expand All @@ -1155,7 +1158,8 @@ describe('V2ConnectorsPage', () => {
]);
renderPage();

const roster = (await screen.findByText('Not connectable yet.')).closest('.v2-connector-row') as HTMLElement;
await openInactiveFold();
const roster = (await screen.findByText('Unavailable here.')).closest('.v2-connector-row') as HTMLElement;
expect(roster).toHaveClass('v2-connector-row--not-enabled');
expect(roster.textContent).toContain('Discord');
expect(roster.querySelector('svg path')?.getAttribute('d')).toBe(glyphPath('discord'));
Expand All @@ -1171,14 +1175,15 @@ describe('V2ConnectorsPage', () => {
// this row's class and Ask link, so it also drove the kicker: the keys-set
// render read "not enabled" directly above "Not connectable yet." — asking
// an operator for credentials the instance already has (Vera, #1826).
expect(roster.querySelector('.v2-connector-row__kicker')).toHaveTextContent(/^not yet$/);
expect(roster.querySelector('.v2-connector-row__kicker')).not.toHaveTextContent('not enabled');
// TASK-338: a dead row carries no state word in its kicker; the fold and
// the line say it.
expect(roster.querySelector('.v2-connector-row__kicker')).toBeEmptyDOMElement();

// The Add verb itself must be gone, not merely unused: an offered provider
// is the only thing that may produce it, and there is none in this catalog.
expect(screen.queryByRole('button', { name: 'Add' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Connect a channel' })).toBeNull();
expect(await screen.findByText(/Not yet\. Tell us which channel/)).toBeInTheDocument();
expect(screen.queryByText(/Tell us which channel/)).toBeNull();
});

it('renders an unavailable provider with a state line and an available one with Add', async () => {
Expand All @@ -1189,7 +1194,8 @@ describe('V2ConnectorsPage', () => {
axios.post.mockResolvedValue({ data: { status: 'installing' } });
renderPage();

expect(await screen.findByText('Not enabled on this instance.')).toBeInTheDocument();
await openInactiveFold();
expect(await screen.findByText('Not offered on this instance.')).toBeInTheDocument();
// This commit's own copy expectations are NOT merged: they asserted the
// one-pod Telegram sentence TASK-154 reversed, and the absence of the
// "ask your operator" detail — which #1557 landed AFTER the 09-04 ruling
Expand Down Expand Up @@ -1236,11 +1242,13 @@ describe('V2ConnectorsPage', () => {
]);
renderPage();

await openInactiveFold();
const ask = (await screen.findAllByRole('link', { name: 'Ask' }))
.find((link) => link.closest('.v2-connector-row')?.classList.contains('v2-connector-row--not-enabled'));
expect(ask).toBeDefined();
expect(ask?.closest('.v2-connector-row')?.querySelector('.v2-connector-row__kicker'))
.toHaveTextContent(/^not enabled$/);
// TASK-338: the blocker is the line; the kicker carries no state word.
expect(ask?.closest('.v2-connector-row')?.querySelector('.v2-connector-row__kicker')).toBeEmptyDOMElement();
expect(ask?.closest('.v2-connector-row')).toHaveTextContent('Not offered on this instance.');
expect(screen.queryByText('no pod')).toBeNull();

// Positive control: the row that DOES carry a connection still names its
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/v2/__tests__/V2InactiveFold.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import i18n, { i18nReady } from '../../i18n';
import V2InactiveFold from '../components/V2InactiveFold';

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

test('inactive rows are hidden until the shared count fold opens', async () => {
await act(async () => { await i18n.changeLanguage('en'); });
render(<V2InactiveFold count={2}><span>Revoked grant</span><span>Expired grant</span></V2InactiveFold>);

const toggle = screen.getByRole('button', { name: 'inactive · 2' });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('Revoked grant')).not.toBeInTheDocument();

fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Revoked grant')).toBeInTheDocument();
expect(screen.getByText('Expired grant')).toBeInTheDocument();

fireEvent.click(toggle);
expect(screen.queryByText('Revoked grant')).not.toBeInTheDocument();
});

test('the count label follows the Chinese locale', async () => {
await act(async () => { await i18n.changeLanguage('zh-CN'); });
render(<V2InactiveFold count={9}><span>Inactive item</span></V2InactiveFold>);
expect(screen.getByRole('button', { name: '非活跃 · 9' })).toBeInTheDocument();
});
Loading
Loading