diff --git a/docs/design/evidence/task-338-channels-after-1440.png b/docs/design/evidence/task-338-channels-after-1440.png new file mode 100644 index 0000000000..33cd66e820 Binary files /dev/null and b/docs/design/evidence/task-338-channels-after-1440.png differ diff --git a/docs/design/evidence/task-338-channels-after-390.png b/docs/design/evidence/task-338-channels-after-390.png new file mode 100644 index 0000000000..a18f3ec69d Binary files /dev/null and b/docs/design/evidence/task-338-channels-after-390.png differ diff --git a/docs/design/evidence/task-338-channels-before-1440.png b/docs/design/evidence/task-338-channels-before-1440.png new file mode 100644 index 0000000000..84bb97f6c4 Binary files /dev/null and b/docs/design/evidence/task-338-channels-before-1440.png differ diff --git a/docs/design/evidence/task-338-channels-before-390.png b/docs/design/evidence/task-338-channels-before-390.png new file mode 100644 index 0000000000..69851ffa3b Binary files /dev/null and b/docs/design/evidence/task-338-channels-before-390.png differ diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 1c11eeda15..4260d543d0 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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.", @@ -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", @@ -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.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 4b21ed068f..ffaf15d76c 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1963,6 +1963,7 @@ "needsYou_one": "{{count}}项需要你处理", "needsYou_other": "{{count}}项需要你处理" }, + "inactiveFold": "非活跃 · {{n}}", "loading": "正在加载连接器…", "loadError": "无法加载连接器。", "createError": "无法创建连接器。", @@ -1986,8 +1987,6 @@ "footnote": "你会获得一个一次性代码,发送给机器人即可。更多平台即将支持。", "add": "添加", "noPod": "无 Pod", - "notEnabledKicker": "未启用", - "notYetKicker": "暂未支持", "kickerAttention": "提醒", "kickerMirror": "镜像", "kickerOff": "中继已关", @@ -2057,13 +2056,12 @@ "newCode": "新启用码", "nextStep": "下一步", "noPods": "请先加入一个 Pod。", - "notConnectable": "暂不可连接。", + "notConnectable": "此处不可用。", "notConnected": "未连接", - "notEnabled": "此实例尚未启用。", + "notEnabled": "此实例不提供。", "notLinkedDetail": "你的消息还没有去处", "notLinkedLine": "{{title}} · 未连接到 Pod", "notRelaying": "未转发", - "notYetLine": "还没有。告诉我们你需要哪个频道,我们下一个就做。", "nothingSent": "尚未发送任何内容", "pausedEyebrow": "已暂停", "pausedLine": "管理员已暂停。", diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index 04461a7906..5f8a853f06 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -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 () => { @@ -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` @@ -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(); }); @@ -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 () => { @@ -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 () => { @@ -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')); @@ -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 () => { @@ -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 @@ -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 diff --git a/frontend/src/v2/__tests__/V2InactiveFold.test.tsx b/frontend/src/v2/__tests__/V2InactiveFold.test.tsx new file mode 100644 index 0000000000..76bc621aff --- /dev/null +++ b/frontend/src/v2/__tests__/V2InactiveFold.test.tsx @@ -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(Revoked grantExpired grant); + + 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(Inactive item); + expect(screen.getByRole('button', { name: '非活跃 · 9' })).toBeInTheDocument(); +}); diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index c5d19bbb12..092b27764e 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -16,6 +16,7 @@ import V2ConnectorTools, { type HostedMcpManualRevokeNotice } from './V2Connecto import { hostedMcpErrorNotice } from './hostedMcpErrorNotice'; import { localizeRelativeTime } from '../utils/localizeRelativeTime'; import V2PageHead from './V2PageHead'; +import V2InactiveFold from './V2InactiveFold'; interface ConnectorGate { enabled?: boolean; @@ -955,7 +956,7 @@ const V2ConnectorsPage: React.FC = () => { // raised on the row; everything else in the commit is unchanged. detail: t('connectors.askOperator', { defaultValue: 'ask your operator' }), dot: 'not-yet', - line: t('connectors.notEnabled', { defaultValue: 'Not enabled on this instance.' }), + line: t('connectors.notEnabled', { defaultValue: 'Not offered on this instance.' }), muted: true, notEnabled: true, pulse: false, @@ -971,7 +972,7 @@ const V2ConnectorsPage: React.FC = () => { action: null, detail: '', dot: 'not-yet', - line: t('connectors.notConnectable', { defaultValue: 'Not connectable yet.' }), + line: t('connectors.notConnectable', { defaultValue: 'Unavailable here.' }), muted: true, // The class and the Ask link are right for this row; the kicker was the // part that lied. Measured in the keys-set render at #1826: the eyebrow @@ -1155,16 +1156,13 @@ const V2ConnectorsPage: React.FC = () => { // last-used; a connection with no pod says so. Both parts come from keys and // the timestamp, never from rendered English (Vera, Connectors 70300). const kickerFor = (item: ListItem, row: ConnectorRow): string => { + // Inactive rows sit under the "inactive · N" fold. Their state is the fold's + // job, so the kicker carries no pod and no state word. + if (row.muted) return ''; const podId = item.connector ? connectorPodId(item.connector) : null; // TASK-140: a not-enabled row has no connection to name a pod for, so the // pod slot read `no pod` — which is false, it names the blocker instead. - let pod = row.notEnabled - ? t('connectors.notEnabledKicker', { defaultValue: 'not enabled' }) - : podId ? podNameById(podId, item.connector) : t('connectors.noPod', { defaultValue: 'no pod' }); - // `notEnabled` covers two states and the kicker must not: a not-connectable - // row has this instance's credentials, so it says "not yet" — the same - // string the not-yet roster row renders. - if (row.notConnectable) pod = t('connectors.notYetKicker', { defaultValue: 'not yet' }); + const pod = podId ? podNameById(podId, item.connector) : t('connectors.noPod', { defaultValue: 'no pod' }); // A row with no age (the not-enabled row's '—') carries only the pod. return row.when === '—' ? pod : `${pod} · ${row.when}`; }; @@ -1209,6 +1207,15 @@ const V2ConnectorsPage: React.FC = () => { return undefined; }; + // Live rows first; every dead row (not offered, unavailable, or not enabled) + // goes under the one shared "inactive · N" fold. + const isInactive = (item: ListItem): boolean => { + const row = rowForItem(item); + return Boolean(row.muted || row.notEnabled); + }; + const liveItems = items.filter((item) => !isInactive(item)); + const inactiveItems = items.filter(isInactive); + const busyFor = (item: ListItem): boolean => busyId === item.key || (item.connector !== null && busyId === item.connector._id); const renderRow = (item: ListItem) => { @@ -1665,28 +1672,10 @@ const V2ConnectorsPage: React.FC = () => {
- {items.map(renderRow)} -
- - - - {t('connectors.notYetKicker', { defaultValue: 'not yet' })} - {t('connectors.notYetLine', { defaultValue: 'Not yet. Tell us which channel you need and we build it next.' })} - - - {t('connectors.ask', { defaultValue: 'Ask' })} - -
+ {liveItems.map(renderRow)} + + {inactiveItems.map(renderRow)} +
{availableProviders.length > 0 && ( diff --git a/frontend/src/v2/components/V2InactiveFold.tsx b/frontend/src/v2/components/V2InactiveFold.tsx new file mode 100644 index 0000000000..589ce2ca8e --- /dev/null +++ b/frontend/src/v2/components/V2InactiveFold.tsx @@ -0,0 +1,31 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +type V2InactiveFoldProps = { + count: number; + children: React.ReactNode; +}; + +/** Shared collapsed roster for inactive channel and tool connection rows. */ +const V2InactiveFold: React.FC = ({ count, children }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + if (count === 0) return null; + + return ( +
+ + {open &&
{children}
} +
+ ); +}; + +export default V2InactiveFold; diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 31959827fa..768501f123 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -10506,6 +10506,13 @@ body.modern-ui.v2-canvas { .v2-connectors__content { display: grid; grid-template-columns: minmax(min-content, 1fr) minmax(240px, 400px); gap: 40px; align-items: start; } .v2-connectors__main { display: flex; min-width: 0; min-height: 0; flex-direction: column; } .v2-connectors__rows { overflow: hidden; border: 1px solid var(--v2-border-soft); border-radius: var(--v2-radius-sm); } +.v2-connectors__inactive-fold { min-width: 0; } +.v2-root button.v2-connectors__inactive-toggle { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 10px 18px; border: 0; background: var(--v2-surface); color: var(--v2-text-tertiary); font: 500 12px/16px var(--v2-font-mono); text-align: left; cursor: pointer; } +.v2-root button.v2-connectors__inactive-toggle:hover { background: var(--v2-surface-hover); } +.v2-root button.v2-connectors__inactive-toggle:focus-visible { box-shadow: var(--v2-focus-ring); outline: none; } +.v2-connectors__inactive-caret { width: 7px; height: 7px; border-right: 1px solid currentColor; border-bottom: 1px solid currentColor; transform: rotate(-45deg); } +.v2-root button.v2-connectors__inactive-toggle[aria-expanded="true"] .v2-connectors__inactive-caret { transform: rotate(45deg); } +.v2-connectors__inactive-rows > .v2-connector-row:last-child { border-bottom: 0; } .v2-connector-row { display: grid;