mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(channels): unify the messaging default-channel selector into one grid (#4109)
This commit is contained in:
@@ -903,6 +903,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': 'خطأ',
|
||||
'channels.status.configuring': 'جارٍ الضبط',
|
||||
'channels.defaultMessaging': 'قناة المراسلة الافتراضية',
|
||||
'channels.setAsDefault': 'تعيين كافتراضي',
|
||||
'channels.defaultBadge': 'افتراضي',
|
||||
'webhooks.title': 'الـ Webhooks',
|
||||
'webhooks.create': 'إنشاء Webhook',
|
||||
'webhooks.noWebhooks': 'لا توجد Webhooks مضبوطة',
|
||||
|
||||
@@ -926,6 +926,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': 'ত্রুটি',
|
||||
'channels.status.configuring': 'কনফিগার হচ্ছে',
|
||||
'channels.defaultMessaging': 'ডিফল্ট মেসেজিং চ্যানেল',
|
||||
'channels.setAsDefault': 'ডিফল্ট হিসেবে সেট করুন',
|
||||
'channels.defaultBadge': 'ডিফল্ট',
|
||||
'webhooks.title': 'ওয়েবহুক',
|
||||
'webhooks.create': 'Webhook তৈরি করুন',
|
||||
'webhooks.noWebhooks': 'কোনো webhook কনফিগার করা হয়নি',
|
||||
|
||||
@@ -949,6 +949,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': 'Fehler',
|
||||
'channels.status.configuring': 'Konfigurieren',
|
||||
'channels.defaultMessaging': 'Standard-Messaging-Kanal',
|
||||
'channels.setAsDefault': 'Als Standard festlegen',
|
||||
'channels.defaultBadge': 'Standard',
|
||||
'webhooks.title': 'Webhooks',
|
||||
'webhooks.create': 'Erstelle einen Webhook',
|
||||
'webhooks.noWebhooks': 'Keine Webhooks konfiguriert',
|
||||
|
||||
@@ -1259,6 +1259,8 @@ const en: TranslationMap = {
|
||||
'channels.status.error': 'Error',
|
||||
'channels.status.configuring': 'Configuring',
|
||||
'channels.defaultMessaging': 'Default Messaging Channel',
|
||||
'channels.setAsDefault': 'Set as default',
|
||||
'channels.defaultBadge': 'Default',
|
||||
|
||||
// Webhooks
|
||||
'webhooks.title': 'Webhooks',
|
||||
|
||||
@@ -944,6 +944,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': 'error',
|
||||
'channels.status.configuring': 'Configurando',
|
||||
'channels.defaultMessaging': 'Canal de mensajería predeterminado',
|
||||
'channels.setAsDefault': 'Establecer como predeterminado',
|
||||
'channels.defaultBadge': 'Predeterminado',
|
||||
'webhooks.title': 'Ganchos web',
|
||||
'webhooks.create': 'Crear webhook',
|
||||
'webhooks.noWebhooks': 'Sin webhooks configurados',
|
||||
|
||||
@@ -947,6 +947,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': 'Erreur',
|
||||
'channels.status.configuring': 'Configuration en cours',
|
||||
'channels.defaultMessaging': 'Canal de messagerie par défaut',
|
||||
'channels.setAsDefault': 'Définir par défaut',
|
||||
'channels.defaultBadge': 'Par défaut',
|
||||
'webhooks.title': 'Webhooks',
|
||||
'webhooks.create': 'Créer un webhook',
|
||||
'webhooks.noWebhooks': 'Aucun webhook configuré',
|
||||
|
||||
@@ -922,6 +922,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': 'एरर',
|
||||
'channels.status.configuring': 'कॉन्फिगर हो रहा है',
|
||||
'channels.defaultMessaging': 'डिफ़ॉल्ट मैसेजिंग चैनल',
|
||||
'channels.setAsDefault': 'डिफ़ॉल्ट के रूप में सेट करें',
|
||||
'channels.defaultBadge': 'डिफ़ॉल्ट',
|
||||
'webhooks.title': 'वेबहुक्स',
|
||||
'webhooks.create': 'Webhook बनाएं',
|
||||
'webhooks.noWebhooks': 'कोई webhook कॉन्फिगर नहीं है',
|
||||
|
||||
@@ -929,6 +929,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': 'Kesalahan',
|
||||
'channels.status.configuring': 'Mengonfigurasi',
|
||||
'channels.defaultMessaging': 'Kanal Pesan Default',
|
||||
'channels.setAsDefault': 'Jadikan bawaan',
|
||||
'channels.defaultBadge': 'Bawaan',
|
||||
'webhooks.title': 'Webhook',
|
||||
'webhooks.create': 'Buat Webhook',
|
||||
'webhooks.noWebhooks': 'Belum ada webhook yang dikonfigurasi',
|
||||
|
||||
@@ -941,6 +941,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': 'Errore',
|
||||
'channels.status.configuring': 'Configurazione',
|
||||
'channels.defaultMessaging': 'Canale di messaggistica predefinito',
|
||||
'channels.setAsDefault': 'Imposta come predefinito',
|
||||
'channels.defaultBadge': 'Predefinito',
|
||||
'webhooks.title': 'Webhook',
|
||||
'webhooks.create': 'Crea webhook',
|
||||
'webhooks.noWebhooks': 'Nessun webhook configurato',
|
||||
|
||||
@@ -922,6 +922,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': '오류',
|
||||
'channels.status.configuring': '구성 중',
|
||||
'channels.defaultMessaging': '기본 메시징 채널',
|
||||
'channels.setAsDefault': '기본값으로 설정',
|
||||
'channels.defaultBadge': '기본값',
|
||||
'webhooks.title': '웹훅',
|
||||
'webhooks.create': '웹훅 생성',
|
||||
'webhooks.noWebhooks': '구성된 웹훅이 없습니다',
|
||||
|
||||
@@ -936,6 +936,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': 'Błąd',
|
||||
'channels.status.configuring': 'Konfigurowanie',
|
||||
'channels.defaultMessaging': 'Domyślny kanał wiadomości',
|
||||
'channels.setAsDefault': 'Ustaw jako domyślny',
|
||||
'channels.defaultBadge': 'Domyślny',
|
||||
'webhooks.title': 'Webhooki',
|
||||
'webhooks.create': 'Utwórz webhook',
|
||||
'webhooks.noWebhooks': 'Brak skonfigurowanych webhooków',
|
||||
|
||||
@@ -946,6 +946,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': 'Erro',
|
||||
'channels.status.configuring': 'Configurando',
|
||||
'channels.defaultMessaging': 'Canal de Mensagens Padrão',
|
||||
'channels.setAsDefault': 'Definir como padrão',
|
||||
'channels.defaultBadge': 'Padrão',
|
||||
'webhooks.title': 'Webhooks',
|
||||
'webhooks.create': 'Criar Webhook',
|
||||
'webhooks.noWebhooks': 'Nenhum webhook configurado',
|
||||
|
||||
@@ -933,6 +933,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': 'Ошибка',
|
||||
'channels.status.configuring': 'Настройка',
|
||||
'channels.defaultMessaging': 'Канал по умолчанию',
|
||||
'channels.setAsDefault': 'Сделать по умолчанию',
|
||||
'channels.defaultBadge': 'По умолчанию',
|
||||
'webhooks.title': 'Вебхуки',
|
||||
'webhooks.create': 'Создать вебхук',
|
||||
'webhooks.noWebhooks': 'Вебхуки не настроены',
|
||||
|
||||
@@ -882,6 +882,8 @@ const messages: TranslationMap = {
|
||||
'channels.status.error': '错误',
|
||||
'channels.status.configuring': '配置中',
|
||||
'channels.defaultMessaging': '默认消息渠道',
|
||||
'channels.setAsDefault': '设为默认',
|
||||
'channels.defaultBadge': '默认',
|
||||
'webhooks.title': 'Webhook',
|
||||
'webhooks.create': '创建 Webhook',
|
||||
'webhooks.noWebhooks': '未配置任何 Webhook',
|
||||
|
||||
+137
-66
@@ -256,9 +256,27 @@ interface ChannelTileProps {
|
||||
icon: React.ReactNode;
|
||||
testId?: string;
|
||||
onOpen: () => void;
|
||||
/** Whether this channel is the current default messaging channel. */
|
||||
isDefault: boolean;
|
||||
/** Set this channel as the default. */
|
||||
onSetDefault: () => void;
|
||||
/** Test id for the "set as default" control (kept stable for E2E). */
|
||||
setDefaultTestId?: string;
|
||||
/** Disable the default control while a write is in flight. */
|
||||
setDefaultBusy?: boolean;
|
||||
}
|
||||
|
||||
function ChannelTile({ def, status, icon, testId, onOpen }: ChannelTileProps) {
|
||||
function ChannelTile({
|
||||
def,
|
||||
status,
|
||||
icon,
|
||||
testId,
|
||||
onOpen,
|
||||
isDefault,
|
||||
onSetDefault,
|
||||
setDefaultTestId,
|
||||
setDefaultBusy,
|
||||
}: ChannelTileProps) {
|
||||
const { t } = useT();
|
||||
const isConnected = status === 'connected';
|
||||
const isPending = status === 'connecting';
|
||||
@@ -266,34 +284,81 @@ function ChannelTile({ def, status, icon, testId, onOpen }: ChannelTileProps) {
|
||||
const statusLabel = channelStatusLabel(status, t);
|
||||
const ctaLabel = isConnected ? t('skills.configure') : t('channels.setup');
|
||||
|
||||
// Horizontal tile: icon on the left; name → status → default control stacked
|
||||
// on the right. The tile is a container (not one button) so "configure" (the
|
||||
// icon + name row) and "set as default" stay distinct, focusable controls —
|
||||
// collapsing the old two-selector layout (connect grid + separate picker)
|
||||
// into one place. "Set as default" only appears for channels you can actually
|
||||
// route through (connected); the default badge still shows on whichever
|
||||
// channel is persisted as default, connected or not.
|
||||
const showDefaultControl = isDefault || isConnected;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
onClick={onOpen}
|
||||
title={`${def.display_name} — ${def.description}`}
|
||||
aria-label={`${def.display_name}, ${statusLabel}. ${ctaLabel}.`}
|
||||
className={`group flex flex-col items-center gap-2 rounded-2xl border p-3 pb-3 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 ${
|
||||
<div
|
||||
className={`group flex flex-col gap-2 rounded-2xl border p-3 transition-colors ${
|
||||
isConnected
|
||||
? 'border-sage-300 bg-sage-50/80 shadow-[0_0_0_1px_rgba(34,197,94,0.12)] hover:bg-sage-50 dark:border-sage-500/30 dark:bg-sage-500/10 dark:hover:bg-sage-500/15'
|
||||
? 'border-sage-300 bg-sage-50/80 shadow-[0_0_0_1px_rgba(34,197,94,0.12)] dark:border-sage-500/30 dark:bg-sage-500/10'
|
||||
: isPending
|
||||
? 'border-amber-200 bg-amber-50/40 hover:bg-amber-50/70 dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15'
|
||||
? 'border-amber-200 bg-amber-50/40 dark:border-amber-500/30 dark:bg-amber-500/10'
|
||||
: isError
|
||||
? 'border-coral-200 bg-coral-50/30 hover:bg-coral-50/50 dark:border-coral-500/30 dark:bg-coral-500/10 dark:hover:bg-coral-500/15'
|
||||
: 'border-stone-200 bg-white hover:bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800/60'
|
||||
? 'border-coral-200 bg-coral-50/30 dark:border-coral-500/30 dark:bg-coral-500/10'
|
||||
: 'border-stone-200 bg-white dark:border-neutral-800 dark:bg-neutral-900'
|
||||
} ${
|
||||
// The default channel keeps its connection-status colour but gains a
|
||||
// primary ring so "which one is the default" reads at a glance without
|
||||
// masking whether it is connected.
|
||||
isDefault
|
||||
? 'ring-2 ring-primary-400 ring-offset-1 ring-offset-white dark:ring-offset-neutral-900'
|
||||
: ''
|
||||
}`}>
|
||||
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center text-stone-700 dark:text-neutral-200 [&>span]:h-12 [&>span]:w-12 [&>span]:rounded-2xl [&_svg]:h-7 [&_svg]:w-7">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex min-h-[2.5rem] w-full min-w-0 flex-col items-center justify-start gap-0.5">
|
||||
<span className="line-clamp-2 text-[11px] font-semibold leading-tight text-stone-900 dark:text-neutral-100">
|
||||
{def.display_name}
|
||||
</span>
|
||||
<span className={`line-clamp-1 text-[10px] font-medium ${channelStatusColor(status)}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
onClick={onOpen}
|
||||
title={`${def.display_name} — ${def.description}`}
|
||||
aria-label={`${def.display_name}, ${statusLabel}. ${ctaLabel}.`}
|
||||
className="flex w-full items-center gap-3 rounded-xl text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40">
|
||||
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center text-stone-700 dark:text-neutral-200 [&>span]:h-10 [&>span]:w-10 [&>span]:rounded-2xl [&_svg]:h-6 [&_svg]:w-6">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="line-clamp-2 text-xs font-semibold leading-tight text-stone-900 dark:text-neutral-100">
|
||||
{def.display_name}
|
||||
</span>
|
||||
<span className={`line-clamp-1 text-[11px] font-medium ${channelStatusColor(status)}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{showDefaultControl && (
|
||||
// Aligns under the name/status text (icon 2.5rem + gap 0.75rem).
|
||||
<div className="pl-[3.25rem]">
|
||||
{isDefault ? (
|
||||
<span
|
||||
data-testid={setDefaultTestId}
|
||||
className="inline-flex items-center justify-center gap-1 rounded-lg border border-primary-400/60 bg-primary-100/70 px-2.5 py-1 text-[11px] font-semibold text-primary-700 dark:border-primary-500/40 dark:bg-primary-500/15 dark:text-primary-200">
|
||||
<svg className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.704 5.29a1 1 0 010 1.42l-7.5 7.5a1 1 0 01-1.42 0l-3.5-3.5a1 1 0 111.42-1.42l2.79 2.79 6.79-6.79a1 1 0 011.42 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t('channels.defaultBadge')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={setDefaultTestId}
|
||||
onClick={onSetDefault}
|
||||
disabled={setDefaultBusy}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-stone-200 bg-white/70 px-2.5 py-1 text-[11px] font-medium text-stone-500 transition-colors hover:border-primary-300 hover:text-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900/60 dark:text-neutral-400 dark:hover:border-primary-500/40 dark:hover:text-primary-300">
|
||||
{t('channels.setAsDefault')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1031,49 +1096,55 @@ export default function Skills() {
|
||||
{t('channels.defaultMessaging')}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-2 sm:gap-3"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
|
||||
{channelsGroup.items.map(item => (
|
||||
<div key={item.id} data-testid={`skill-row-${item.id}`}>
|
||||
<ChannelTile
|
||||
def={item.channelDef!}
|
||||
status={item.channelStatus!}
|
||||
icon={item.icon}
|
||||
testId={`skill-install-${item.id}`}
|
||||
onOpen={() => setChannelModalDef(item.channelDef!)}
|
||||
/>
|
||||
{/* One unified surface: each tile shows connection status,
|
||||
opens setup/configure on click, and owns the "default
|
||||
messaging channel" selection via its footer control.
|
||||
Connected channels and not-yet-connected channels are
|
||||
rendered as two separate grids (no divider/label) so
|
||||
each group occupies its own rows. */}
|
||||
{(() => {
|
||||
// The built-in web channel needs no connection — treat it
|
||||
// as always available so it stays selectable as default.
|
||||
const statusFor = (def: ChannelDefinition): ChannelConnectionStatus =>
|
||||
def.id === 'web' ? 'connected' : bestChannelStatus(def.id as ChannelType);
|
||||
const renderTile = (def: ChannelDefinition) => {
|
||||
const channelId = def.id as ChannelType;
|
||||
return (
|
||||
<div key={channelId} data-testid={`skill-row-channel-${channelId}`}>
|
||||
<ChannelTile
|
||||
def={def}
|
||||
status={statusFor(def)}
|
||||
icon={channelIcons[def.icon]}
|
||||
testId={`skill-install-channel-${channelId}`}
|
||||
onOpen={() => setChannelModalDef(def)}
|
||||
isDefault={channelConnections.defaultMessagingChannel === channelId}
|
||||
onSetDefault={() => void handleSetDefaultChannel(channelId)}
|
||||
setDefaultTestId={`channel-select-${channelId}`}
|
||||
setDefaultBusy={defaultChannelBusy !== null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const connected = channelDefs.filter(d => statusFor(d) === 'connected');
|
||||
const notConnected = channelDefs.filter(d => statusFor(d) !== 'connected');
|
||||
const gridStyle = {
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(13rem, 1fr))',
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{connected.length > 0 && (
|
||||
<div className="grid gap-2 sm:gap-3" style={gridStyle}>
|
||||
{connected.map(renderTile)}
|
||||
</div>
|
||||
)}
|
||||
{notConnected.length > 0 && (
|
||||
<div className="grid gap-2 sm:gap-3" style={gridStyle}>
|
||||
{notConnected.map(renderTile)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-stone-100 dark:border-neutral-800">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400 mb-2">
|
||||
{t('channels.defaultMessaging')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{channelDefs.map(def => {
|
||||
const channelId = def.id as ChannelType;
|
||||
const selected =
|
||||
channelConnections.defaultMessagingChannel === channelId;
|
||||
return (
|
||||
<button
|
||||
key={channelId}
|
||||
type="button"
|
||||
data-testid={`channel-select-${channelId}`}
|
||||
onClick={() => void handleSetDefaultChannel(channelId)}
|
||||
disabled={defaultChannelBusy !== null}
|
||||
className={`rounded-lg border px-3 py-2 text-xs font-medium transition-colors ${
|
||||
selected
|
||||
? 'border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300'
|
||||
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700'
|
||||
}`}>
|
||||
{def.display_name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -24,14 +24,33 @@ const imessageDef: ChannelDefinition = {
|
||||
capabilities: [],
|
||||
};
|
||||
|
||||
// The built-in web channel needs no connection, so the grid always renders it
|
||||
// as connected — making it the reliable "connected, not default" tile.
|
||||
const webDef: ChannelDefinition = {
|
||||
id: 'web',
|
||||
display_name: 'Web',
|
||||
description: 'Chat via the built-in web UI.',
|
||||
icon: 'web',
|
||||
auth_modes: [],
|
||||
capabilities: [],
|
||||
};
|
||||
|
||||
vi.mock('../../hooks/useChannelDefinitions', () => ({
|
||||
useChannelDefinitions: () => ({
|
||||
definitions: [telegramDef, imessageDef],
|
||||
definitions: [telegramDef, imessageDef, webDef],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { updatePreferencesMock } = vi.hoisted(() => ({
|
||||
updatePreferencesMock: vi.fn<(channel: string) => Promise<void>>(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/api/channelConnectionsApi', () => ({
|
||||
channelConnectionsApi: { updatePreferences: (channel: string) => updatePreferencesMock(channel) },
|
||||
}));
|
||||
|
||||
vi.mock('../../services/api/workflowsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/api/workflowsApi')>(
|
||||
'../../services/api/workflowsApi'
|
||||
@@ -64,6 +83,76 @@ vi.mock('../../lib/composio/hooks', () => ({
|
||||
describe('Skills page — Channels grid', () => {
|
||||
beforeEach(() => {
|
||||
// The default tab is 'composio'; click 'Messaging' to reveal the Channels card.
|
||||
updatePreferencesMock.mockReset();
|
||||
updatePreferencesMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('groups connected channels ahead of not-connected ones', () => {
|
||||
const preloadedState = {
|
||||
channelConnections: {
|
||||
schemaVersion: 1,
|
||||
migrationCompleted: true,
|
||||
defaultMessagingChannel: 'web' as const,
|
||||
connections: {
|
||||
telegram: {
|
||||
managed_dm: undefined,
|
||||
oauth: {
|
||||
channel: 'telegram' as const,
|
||||
authMode: 'oauth' as const,
|
||||
status: 'connected' as const,
|
||||
selectedDefault: false,
|
||||
lastError: null,
|
||||
capabilities: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
bot_token: undefined,
|
||||
api_key: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(<Skills />, { initialEntries: ['/connections'], preloadedState });
|
||||
fireEvent.click(screen.getByTestId('two-pane-nav-channels'));
|
||||
|
||||
const channelsCard = screen.getByRole('heading', { name: 'Messaging' }).closest('.rounded-2xl');
|
||||
const order = within(channelsCard as HTMLElement)
|
||||
.getAllByTestId(/^skill-row-channel-/)
|
||||
.map(el => el.getAttribute('data-testid'));
|
||||
|
||||
// Connected channels (Telegram + always-on Web) come before the
|
||||
// disconnected one (iMessage), with no separator between the groups.
|
||||
const idxTelegram = order.indexOf('skill-row-channel-telegram');
|
||||
const idxWeb = order.indexOf('skill-row-channel-web');
|
||||
const idxImessage = order.indexOf('skill-row-channel-imessage');
|
||||
expect(idxTelegram).toBeGreaterThanOrEqual(0);
|
||||
expect(idxWeb).toBeGreaterThanOrEqual(0);
|
||||
expect(idxImessage).toBeGreaterThan(idxTelegram);
|
||||
expect(idxImessage).toBeGreaterThan(idxWeb);
|
||||
});
|
||||
|
||||
it('offers "set as default" only on connected tiles and switches the default', async () => {
|
||||
const { store } = renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
|
||||
fireEvent.click(screen.getByTestId('two-pane-nav-channels'));
|
||||
|
||||
const channelsCard = screen.getByRole('heading', { name: 'Messaging' }).closest('.rounded-2xl');
|
||||
const within$ = within(channelsCard as HTMLElement);
|
||||
|
||||
// The redux default starts on Telegram, so its tile shows the "Default"
|
||||
// badge (there is no longer a second, separate channel-picker list below).
|
||||
expect(within$.getByTestId('channel-select-telegram')).toHaveTextContent('Default');
|
||||
// Web is always connected but not the default → offers "Set as default".
|
||||
const setWebDefault = within$.getByTestId('channel-select-web');
|
||||
expect(setWebDefault).toHaveTextContent('Set as default');
|
||||
// A disconnected, non-default channel offers no default control at all.
|
||||
expect(within$.queryByTestId('channel-select-imessage')).toBeNull();
|
||||
|
||||
fireEvent.click(setWebDefault);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getState().channelConnections.defaultMessagingChannel).toBe('web')
|
||||
);
|
||||
expect(updatePreferencesMock).toHaveBeenCalledWith('web');
|
||||
});
|
||||
|
||||
it('renders configured channels as tiles in a dedicated card and opens the setup modal on click', async () => {
|
||||
@@ -143,7 +232,12 @@ describe('Skills page — Channels grid', () => {
|
||||
const telegramTile = within(channelsCard as HTMLElement).getByRole('button', {
|
||||
name: new RegExp(`Telegram.*${labelPattern.source}`, 'i'),
|
||||
});
|
||||
expect(telegramTile.className).toMatch(classPattern);
|
||||
// The connection-status colour now lives on the tile container (the
|
||||
// inner button only owns the "configure" affordance), so assert against
|
||||
// the wrapping tile rather than the button itself.
|
||||
const tileContainer = telegramTile.closest('.rounded-2xl');
|
||||
expect(tileContainer).not.toBeNull();
|
||||
expect((tileContainer as HTMLElement).className).toMatch(classPattern);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -11,11 +11,26 @@
|
||||
* - 13.2.2 Privacy panel renders + analytics toggle is present
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { clickSelector, textExists, waitForText } from '../helpers/element-helpers';
|
||||
import { resetApp } from '../helpers/reset-app';
|
||||
import { navigateViaHash } from '../helpers/shared-flows';
|
||||
import { startMockServer, stopMockServer } from '../mock-server';
|
||||
|
||||
/** Read the persisted default messaging channel from the renderer's redux store. */
|
||||
async function defaultMessagingChannel(): Promise<string | null> {
|
||||
return browser.execute(() => {
|
||||
const win = window as unknown as {
|
||||
__OPENHUMAN_STORE__?: {
|
||||
getState?: () => { channelConnections?: { defaultMessagingChannel?: string | null } };
|
||||
};
|
||||
};
|
||||
return (
|
||||
win.__OPENHUMAN_STORE__?.getState?.().channelConnections?.defaultMessagingChannel ?? null
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const USER_ID = 'e2e-settings-channels';
|
||||
|
||||
describe('Settings - Channels & Permissions', () => {
|
||||
@@ -30,35 +45,37 @@ describe('Settings - Channels & Permissions', () => {
|
||||
});
|
||||
|
||||
it('allows switching default messaging channel (13.2.1)', async () => {
|
||||
// Phase 2: Default Messaging Channel UI is at /connections (Messaging tab).
|
||||
// Old /skills?tab=channels → /connections?tab=messaging.
|
||||
// The messaging panel now offers "Set as default" only on *connected*
|
||||
// channels. In a fresh workspace the only always-connected channel is Web
|
||||
// (the built-in chat), so we make Telegram the default first — that turns
|
||||
// Web into a connected, non-default tile that exposes the control — then
|
||||
// switch the default to Web through the UI.
|
||||
await callOpenhumanRpc('openhuman.channels_set_default', { channel: 'telegram' });
|
||||
|
||||
// Navigate away and back so the messaging panel re-seeds the default from
|
||||
// the core (it reads the persisted default when the page mounts).
|
||||
await navigateViaHash('/home');
|
||||
await navigateViaHash('/connections?tab=messaging');
|
||||
|
||||
await waitForText('Default Messaging Channel', 15_000);
|
||||
expect(await textExists('Telegram')).toBe(true);
|
||||
expect(await textExists('Discord')).toBe(true);
|
||||
expect(await textExists('Web')).toBe(true);
|
||||
|
||||
// Select via the stable channel-select test id rather than the ambiguous
|
||||
// "Discord" text (which also appears on connection tiles / help copy).
|
||||
await clickSelector('[data-testid="channel-select-discord"]');
|
||||
// Confirm the selection persisted to redux state (the Connections messaging
|
||||
// tab no longer renders the legacy "Active route" line).
|
||||
await browser.waitUntil(
|
||||
async () =>
|
||||
(await browser.execute(() => {
|
||||
const win = window as unknown as {
|
||||
__OPENHUMAN_STORE__?: {
|
||||
getState?: () => { channelConnections?: { defaultMessagingChannel?: string | null } };
|
||||
};
|
||||
};
|
||||
return win.__OPENHUMAN_STORE__?.getState?.().channelConnections?.defaultMessagingChannel;
|
||||
})) === 'discord',
|
||||
{
|
||||
timeout: 10_000,
|
||||
interval: 500,
|
||||
timeoutMsg: 'default messaging channel did not switch to discord',
|
||||
}
|
||||
);
|
||||
// Confirm the panel seeded Telegram as the default before we switch.
|
||||
await browser.waitUntil(async () => (await defaultMessagingChannel()) === 'telegram', {
|
||||
timeout: 10_000,
|
||||
interval: 500,
|
||||
timeoutMsg: 'messaging panel did not seed Telegram as the default',
|
||||
});
|
||||
|
||||
// Switch to Web via the stable channel-select test id (its "Set as default"
|
||||
// control is present because Web is always connected).
|
||||
await clickSelector('[data-testid="channel-select-web"]');
|
||||
await browser.waitUntil(async () => (await defaultMessagingChannel()) === 'web', {
|
||||
timeout: 10_000,
|
||||
interval: 500,
|
||||
timeoutMsg: 'default messaging channel did not switch to web',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders privacy settings and analytics toggle (13.2.2)', async () => {
|
||||
|
||||
@@ -82,15 +82,27 @@ describe('Settings - Feature Preferences', () => {
|
||||
});
|
||||
|
||||
it('persists the default messaging channel through redux state', async () => {
|
||||
// Phase 2: Default Messaging Channel moved to /connections (Messaging tab).
|
||||
// Old /skills?tab=channels → /connections?tab=messaging.
|
||||
// The messaging panel exposes "Set as default" only on *connected* channels.
|
||||
// In a fresh workspace the only always-connected channel is Web (built-in
|
||||
// chat), so make Telegram the default first — that turns Web into a
|
||||
// connected, non-default tile with the control — then switch to Web.
|
||||
await callOpenhumanRpc('openhuman.channels_set_default', { channel: 'telegram' });
|
||||
|
||||
// Navigate away and back so the panel re-seeds the default from the core.
|
||||
await navigateViaHash('/home');
|
||||
await navigateViaHash('/connections?tab=messaging');
|
||||
|
||||
await waitForText('Default Messaging Channel', 15_000);
|
||||
// Use the stable channel-select test id — "Discord" text also appears on
|
||||
// connection tiles and help copy, so clickText could hit the wrong node.
|
||||
await clickSelector('[data-testid="channel-select-discord"]', 10_000);
|
||||
await browser.waitUntil(async () => (await defaultMessagingChannelFromStore()) === 'discord', {
|
||||
await browser.waitUntil(async () => (await defaultMessagingChannelFromStore()) === 'telegram', {
|
||||
timeout: 10_000,
|
||||
interval: 500,
|
||||
timeoutMsg: 'messaging panel did not seed Telegram as the default',
|
||||
});
|
||||
|
||||
// Switch to Web via its stable channel-select test id (Web is always
|
||||
// connected, so its "Set as default" control is present).
|
||||
await clickSelector('[data-testid="channel-select-web"]', 10_000);
|
||||
await browser.waitUntil(async () => (await defaultMessagingChannelFromStore()) === 'web', {
|
||||
timeout: 10_000,
|
||||
interval: 500,
|
||||
timeoutMsg: 'default channel did not update',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
bootAuthenticatedPage,
|
||||
callCoreRpc,
|
||||
dismissWalkthroughIfPresent,
|
||||
waitForAppReady,
|
||||
} from '../helpers/core-rpc';
|
||||
@@ -27,7 +28,14 @@ test.describe('Settings - Channels & Permissions', () => {
|
||||
});
|
||||
|
||||
test('allows switching default messaging channel', async ({ page }) => {
|
||||
// Phase 2: default messaging channel UI moved to /connections (Messaging tab)
|
||||
// "Set as default" now appears only on *connected* channels. In a fresh
|
||||
// workspace the only always-connected channel is Web (built-in chat), so
|
||||
// make Telegram the default first (turning Web into a connected,
|
||||
// non-default tile with the control), then switch the default to Web.
|
||||
await callCoreRpc('openhuman.channels_set_default', { channel: 'telegram' });
|
||||
|
||||
// Phase 2: default messaging channel UI moved to /connections (Messaging tab).
|
||||
// Mounting the panel re-seeds the default from the core.
|
||||
await page.goto('/#/connections?tab=messaging');
|
||||
await waitForAppReady(page);
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
@@ -39,10 +47,13 @@ test.describe('Settings - Channels & Permissions', () => {
|
||||
|
||||
await expect(page.getByText('Default Messaging Channel').last()).toBeVisible();
|
||||
await expect(page.getByText('Telegram').last()).toBeVisible();
|
||||
await expect(page.getByText('Discord').last()).toBeVisible();
|
||||
await expect(page.getByText('Web').last()).toBeVisible();
|
||||
|
||||
await page.getByText('Discord').last().click();
|
||||
await expect.poll(() => getDefaultMessagingChannel(page)).toBe('discord');
|
||||
// Confirm the panel seeded Telegram as the default before switching.
|
||||
await expect.poll(() => getDefaultMessagingChannel(page)).toBe('telegram');
|
||||
|
||||
await page.getByTestId('channel-select-web').click();
|
||||
await expect.poll(() => getDefaultMessagingChannel(page)).toBe('web');
|
||||
});
|
||||
|
||||
test('renders privacy settings and analytics toggle', async ({ page }) => {
|
||||
|
||||
@@ -101,22 +101,27 @@ test.describe('Settings - Feature Preferences', () => {
|
||||
});
|
||||
|
||||
test('persists the default messaging channel through redux state', async ({ page }) => {
|
||||
// Phase 2: default messaging channel moved to /connections (Messaging tab)
|
||||
// Phase 2: default messaging channel moved to /connections (Messaging tab).
|
||||
await openAuthenticatedRoute(page, 'pw-settings-default-channel', '/connections?tab=messaging');
|
||||
|
||||
// "Set as default" now appears only on *connected* channels. In a fresh
|
||||
// workspace the only always-connected channel is Web (built-in chat), so
|
||||
// make Telegram the default first (turning Web into a connected,
|
||||
// non-default tile with the control), reload so the panel re-seeds, then
|
||||
// switch the default to Web.
|
||||
await callCoreRpc('openhuman.channels_set_default', { channel: 'telegram' });
|
||||
await reloadAndWait(page);
|
||||
|
||||
const messagingTab = page.getByTestId('two-pane-nav-channels');
|
||||
if (await messagingTab.isVisible().catch(() => false)) {
|
||||
await messagingTab.click();
|
||||
}
|
||||
|
||||
await expect(page.getByText('Default Messaging Channel').last()).toBeVisible();
|
||||
await page
|
||||
.locator('button')
|
||||
.filter({ hasText: /^Discord$/ })
|
||||
.last()
|
||||
.click();
|
||||
await expect.poll(() => getDefaultMessagingChannel(page)).toBe('telegram');
|
||||
|
||||
await expect.poll(() => getDefaultMessagingChannel(page)).toBe('discord');
|
||||
await page.getByTestId('channel-select-web').click();
|
||||
await expect.poll(() => getDefaultMessagingChannel(page)).toBe('web');
|
||||
});
|
||||
|
||||
test('persists tools preferences to the core app-state snapshot', async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user