Fanout integration UX overhaul

This commit is contained in:
Jack Kingsman
2026-03-06 21:37:11 -08:00
parent 94546f90a4
commit da22eb5c48
8 changed files with 687 additions and 269 deletions
+2 -2
View File
@@ -126,8 +126,8 @@ export function SettingsModal(props: SettingsModalProps) {
const sectionWrapperClass = 'overflow-hidden';
const sectionContentClass = externalDesktopSidebarMode
? 'space-y-4 p-4'
: 'space-y-4 p-4 border-t border-input';
? 'mx-auto w-full max-w-[800px] space-y-4 p-4'
: 'mx-auto w-full max-w-[800px] space-y-4 border-t border-input p-4';
const settingsContainerClass = externalDesktopSidebarMode
? 'w-full h-full overflow-y-auto'
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
@@ -60,6 +60,12 @@ function formatAppriseTargets(urls: string | undefined, maxLength = 80) {
return `${joined.slice(0, maxLength - 3)}...`;
}
function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) {
const label = TYPE_LABELS[type] || type;
const nextIndex = configs.filter((cfg) => cfg.type === type).length + 1;
return `${label} #${nextIndex}`;
}
const DEFAULT_BOT_CODE = `def bot(
sender_name: str | None,
sender_key: str | None,
@@ -1003,10 +1009,15 @@ export function SettingsFanoutSection({
}) {
const [configs, setConfigs] = useState<FanoutConfig[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [draftType, setDraftType] = useState<string | null>(null);
const [editConfig, setEditConfig] = useState<Record<string, unknown>>({});
const [editScope, setEditScope] = useState<Record<string, unknown>>({});
const [editName, setEditName] = useState('');
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
const [inlineEditName, setInlineEditName] = useState('');
const [addMenuOpen, setAddMenuOpen] = useState(false);
const [busy, setBusy] = useState(false);
const addMenuRef = useRef<HTMLDivElement | null>(null);
const loadConfigs = useCallback(async () => {
try {
@@ -1021,6 +1032,19 @@ export function SettingsFanoutSection({
loadConfigs();
}, [loadConfigs]);
useEffect(() => {
if (!addMenuOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (!addMenuRef.current?.contains(event.target as Node)) {
setAddMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [addMenuOpen]);
const handleToggleEnabled = async (cfg: FanoutConfig) => {
try {
await api.updateFanoutConfig(cfg.id, { enabled: !cfg.enabled });
@@ -1033,23 +1057,86 @@ export function SettingsFanoutSection({
};
const handleEdit = (cfg: FanoutConfig) => {
setAddMenuOpen(false);
setInlineEditingId(null);
setInlineEditName('');
setDraftType(null);
setEditingId(cfg.id);
setEditConfig(cfg.config);
setEditScope(cfg.scope);
setEditName(cfg.name);
};
const handleStartInlineEdit = (cfg: FanoutConfig) => {
setAddMenuOpen(false);
setInlineEditingId(cfg.id);
setInlineEditName(cfg.name);
};
const handleCancelInlineEdit = () => {
setInlineEditingId(null);
setInlineEditName('');
};
const handleBackToList = () => {
if (!confirm('Leave without saving?')) return;
setEditingId(null);
setDraftType(null);
};
const handleInlineNameSave = async (cfg: FanoutConfig) => {
const nextName = inlineEditName.trim();
if (inlineEditingId !== cfg.id) return;
if (!nextName) {
toast.error('Name cannot be empty');
handleCancelInlineEdit();
return;
}
if (nextName === cfg.name) {
handleCancelInlineEdit();
return;
}
try {
await api.updateFanoutConfig(cfg.id, { name: nextName });
if (editingId === cfg.id) {
setEditName(nextName);
}
await loadConfigs();
toast.success('Name updated');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update name');
} finally {
handleCancelInlineEdit();
}
};
const handleSave = async (enabled?: boolean) => {
if (!editingId) return;
const currentDraftType = draftType;
const currentEditingId = editingId;
if (!currentEditingId && !currentDraftType) return;
setBusy(true);
try {
const update: Record<string, unknown> = {
name: editName,
config: editConfig,
scope: editScope,
};
if (enabled !== undefined) update.enabled = enabled;
await api.updateFanoutConfig(editingId, update);
if (currentDraftType) {
await api.createFanoutConfig({
type: currentDraftType,
name: editName,
config: editConfig,
scope: editScope,
enabled: enabled ?? true,
});
} else {
if (!currentEditingId) {
throw new Error('Missing fanout config id for update');
}
const update: Record<string, unknown> = {
name: editName,
config: editConfig,
scope: editScope,
};
if (enabled !== undefined) update.enabled = enabled;
await api.updateFanoutConfig(currentEditingId, update);
}
setDraftType(null);
setEditingId(null);
await loadConfigs();
if (onHealthRefresh) {
@@ -1129,33 +1216,33 @@ export function SettingsFanoutSection({
webhook: { messages: 'all', raw_packets: 'none' },
apprise: { messages: 'all', raw_packets: 'none' },
};
try {
const created = await api.createFanoutConfig({
type,
name: TYPE_LABELS[type] || type,
config: defaults[type] || {},
scope: defaultScopes[type] || {},
enabled: false,
});
await loadConfigs();
handleEdit(created);
toast.success('Integration created');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create');
}
setAddMenuOpen(false);
setEditingId(null);
setDraftType(type);
setEditName(getDefaultIntegrationName(type, configs));
setEditConfig(defaults[type] || {});
setEditScope(defaultScopes[type] || {});
};
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
const detailType = draftType ?? editingConfig?.type ?? null;
const isDraft = draftType !== null;
const configGroups = TYPE_OPTIONS.map((opt) => ({
type: opt.value,
label: opt.label,
configs: configs
.filter((cfg) => cfg.type === opt.value)
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
})).filter((group) => group.configs.length > 0);
// Detail view
if (editingConfig) {
if (detailType) {
return (
<div className={cn('space-y-4', className)}>
<div className={cn('mx-auto w-full max-w-[800px] space-y-4', className)}>
<button
type="button"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setEditingId(null)}
className="inline-flex items-center rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-sm text-warning transition-colors hover:bg-warning/20"
onClick={handleBackToList}
>
&larr; Back to list
</button>
@@ -1171,12 +1258,12 @@ export function SettingsFanoutSection({
</div>
<div className="text-xs text-muted-foreground">
Type: {TYPE_LABELS[editingConfig.type] || editingConfig.type}
Type: {TYPE_LABELS[detailType] || detailType}
</div>
<Separator />
{editingConfig.type === 'mqtt_private' && (
{detailType === 'mqtt_private' && (
<MqttPrivateConfigEditor
config={editConfig}
scope={editScope}
@@ -1185,15 +1272,13 @@ export function SettingsFanoutSection({
/>
)}
{editingConfig.type === 'mqtt_community' && (
{detailType === 'mqtt_community' && (
<MqttCommunityConfigEditor config={editConfig} onChange={setEditConfig} />
)}
{editingConfig.type === 'bot' && (
<BotConfigEditor config={editConfig} onChange={setEditConfig} />
)}
{detailType === 'bot' && <BotConfigEditor config={editConfig} onChange={setEditConfig} />}
{editingConfig.type === 'apprise' && (
{detailType === 'apprise' && (
<AppriseConfigEditor
config={editConfig}
scope={editScope}
@@ -1202,7 +1287,7 @@ export function SettingsFanoutSection({
/>
)}
{editingConfig.type === 'webhook' && (
{detailType === 'webhook' && (
<WebhookConfigEditor
config={editConfig}
scope={editScope}
@@ -1229,9 +1314,11 @@ export function SettingsFanoutSection({
>
{busy ? 'Saving...' : 'Save as Disabled'}
</Button>
<Button variant="destructive" onClick={() => handleDelete(editingConfig.id)}>
Delete
</Button>
{!isDraft && editingConfig && (
<Button variant="destructive" onClick={() => handleDelete(editingConfig.id)}>
Delete
</Button>
)}
</div>
</div>
);
@@ -1239,7 +1326,7 @@ export function SettingsFanoutSection({
// List view
return (
<div className={cn('space-y-4', className)}>
<div className={cn('mx-auto w-full max-w-[800px] space-y-4', className)}>
<div className="rounded-md border border-warning/50 bg-warning/10 px-4 py-3 text-sm text-warning">
Integrations are an experimental feature in open beta.
</div>
@@ -1251,134 +1338,194 @@ export function SettingsFanoutSection({
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Add a new entry:</span>
{TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map((opt) => (
<Button
key={opt.value}
variant="outline"
size="sm"
onClick={() => handleAddCreate(opt.value)}
<div className="relative inline-block" ref={addMenuRef}>
<Button
type="button"
size="sm"
aria-haspopup="menu"
aria-expanded={addMenuOpen}
onClick={() => setAddMenuOpen((open) => !open)}
>
Add Integration
</Button>
{addMenuOpen && (
<div
role="menu"
className="absolute left-0 top-full z-10 mt-2 min-w-56 rounded-md border border-input bg-background p-1 shadow-md"
>
{opt.label}
</Button>
))}
{TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
(opt) => (
<button
key={opt.value}
type="button"
role="menuitem"
className="flex w-full rounded-sm px-3 py-2 text-left text-sm hover:bg-muted"
onClick={() => handleAddCreate(opt.value)}
>
{opt.label}
</button>
)
)}
</div>
)}
</div>
{configs.length > 0 && (
<div className="space-y-2">
{configs.map((cfg) => {
const statusEntry = health?.fanout_statuses?.[cfg.id];
const status = cfg.enabled ? statusEntry?.status : undefined;
const communityConfig = cfg.config as Record<string, unknown>;
return (
<div key={cfg.id} className="border border-input rounded-md overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50">
<label
className="flex items-center cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={cfg.enabled}
onChange={() => handleToggleEnabled(cfg)}
className="w-4 h-4 rounded border-input accent-primary"
aria-label={`Enable ${cfg.name}`}
/>
</label>
{configGroups.length > 0 && (
<div className="columns-1 gap-4 md:columns-2">
{configGroups.map((group) => (
<section
key={group.type}
className="mb-4 inline-block w-full break-inside-avoid space-y-2"
aria-label={`${group.label} integrations`}
>
<div className="px-1 text-sm font-medium text-muted-foreground">{group.label}</div>
<div className="space-y-2">
{group.configs.map((cfg) => {
const statusEntry = health?.fanout_statuses?.[cfg.id];
const status = cfg.enabled ? statusEntry?.status : undefined;
const communityConfig = cfg.config as Record<string, unknown>;
return (
<div
key={cfg.id}
role="group"
aria-label={`Integration ${cfg.name}`}
className="border border-input rounded-md overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50">
<label
className="flex items-center cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={cfg.enabled}
onChange={() => handleToggleEnabled(cfg)}
className="w-4 h-4 rounded border-input accent-primary"
aria-label={`Enable ${cfg.name}`}
/>
</label>
<span className="text-sm font-medium flex-1">{cfg.name}</span>
<div className="flex-1 min-w-0">
{inlineEditingId === cfg.id ? (
<Input
value={inlineEditName}
autoFocus
onChange={(e) => setInlineEditName(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => void handleInlineNameSave(cfg)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.currentTarget.blur();
}
if (e.key === 'Escape') {
e.preventDefault();
handleCancelInlineEdit();
}
}}
aria-label={`Edit name for ${cfg.name}`}
className="h-8"
/>
) : (
<button
type="button"
className="block max-w-full cursor-text truncate text-left text-sm font-medium hover:text-foreground/80"
onClick={() => handleStartInlineEdit(cfg)}
>
{cfg.name}
</button>
)}
</div>
<span className="text-xs text-muted-foreground">
{TYPE_LABELS[cfg.type] || cfg.type}
</span>
<div
className={cn(
'w-2 h-2 rounded-full transition-colors',
getStatusColor(status, cfg.enabled)
)}
title={cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
aria-hidden="true"
/>
<span className="text-xs text-muted-foreground hidden sm:inline">
{cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
</span>
<div
className={cn(
'w-2 h-2 rounded-full transition-colors',
getStatusColor(status, cfg.enabled)
)}
title={cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
aria-hidden="true"
/>
<span className="text-xs text-muted-foreground hidden sm:inline">
{cfg.enabled ? getStatusLabel(status, cfg.type) : 'Disabled'}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleEdit(cfg)}
>
Edit
</Button>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => handleEdit(cfg)}
>
Edit
</Button>
</div>
{cfg.type === 'mqtt_community' && (
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
<div>
Broker:{' '}
{formatBrokerSummary(communityConfig, {
host: DEFAULT_COMMUNITY_BROKER_HOST,
port: DEFAULT_COMMUNITY_BROKER_PORT,
})}
</div>
<div className="break-all">
Topic:{' '}
<code>
{(communityConfig.topic_template as string) ||
DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
</code>
</div>
</div>
)}
{cfg.type === 'mqtt_community' && (
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
<div>
Broker:{' '}
{formatBrokerSummary(communityConfig, {
host: DEFAULT_COMMUNITY_BROKER_HOST,
port: DEFAULT_COMMUNITY_BROKER_PORT,
})}
{cfg.type === 'mqtt_private' && (
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
<div>
Broker:{' '}
{formatBrokerSummary(cfg.config as Record<string, unknown>, {
host: '',
port: 1883,
})}
</div>
<div className="break-all">
Topics:{' '}
<code>
{formatPrivateTopicSummary(cfg.config as Record<string, unknown>)}
</code>
</div>
</div>
)}
{cfg.type === 'webhook' && (
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
<div className="break-all">
URL:{' '}
<code>
{((cfg.config as Record<string, unknown>).url as string) || 'Not set'}
</code>
</div>
</div>
)}
{cfg.type === 'apprise' && (
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
<div className="break-all">
Targets:{' '}
<code>
{formatAppriseTargets(
(cfg.config as Record<string, unknown>).urls as string | undefined
)}
</code>
</div>
</div>
)}
</div>
<div className="break-all">
Topic:{' '}
<code>
{(communityConfig.topic_template as string) ||
DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}
</code>
</div>
</div>
)}
{cfg.type === 'mqtt_private' && (
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
<div>
Broker:{' '}
{formatBrokerSummary(cfg.config as Record<string, unknown>, {
host: '',
port: 1883,
})}
</div>
<div className="break-all">
Topics:{' '}
<code>
{formatPrivateTopicSummary(cfg.config as Record<string, unknown>)}
</code>
</div>
</div>
)}
{cfg.type === 'webhook' && (
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
<div className="break-all">
URL:{' '}
<code>
{((cfg.config as Record<string, unknown>).url as string) || 'Not set'}
</code>
</div>
</div>
)}
{cfg.type === 'apprise' && (
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
<div className="break-all">
Targets:{' '}
<code>
{formatAppriseTargets(
(cfg.config as Record<string, unknown>).urls as string | undefined
)}
</code>
</div>
</div>
)}
);
})}
</div>
);
})}
</section>
))}
</div>
)}
</div>
+3
View File
@@ -185,6 +185,9 @@ const baseSettings = {
preferences_migrated: false,
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
blocked_keys: [],
blocked_names: [],
};
const publicChannel = {
+212 -36
View File
@@ -1,4 +1,4 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { SettingsFanoutSection } from '../components/settings/SettingsFanoutSection';
import type { HealthStatus, FanoutConfig } from '../types';
@@ -68,35 +68,36 @@ function renderSectionWithRefresh(
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, 'confirm').mockReturnValue(true);
mockedApi.getFanoutConfigs.mockResolvedValue([]);
mockedApi.getChannels.mockResolvedValue([]);
mockedApi.getContacts.mockResolvedValue([]);
});
describe('SettingsFanoutSection', () => {
it('shows add buttons for all integration types', async () => {
it('shows add integration menu with all integration types', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Private MQTT' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Community MQTT/mesh2mqtt' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Webhook' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Apprise' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Bot' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
expect(screen.getByRole('menuitem', { name: 'Private MQTT' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Community MQTT/mesh2mqtt' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
});
it('shows updated add label phrasing', async () => {
it('shows bot option in add integration menu when bots are enabled', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByText('Add a new entry:')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
});
it('hides bot add button when bots_disabled', async () => {
renderSection({ health: { ...baseHealth, bots_disabled: true } });
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Bot' })).not.toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
});
it('shows bots disabled banner when bots_disabled', async () => {
@@ -106,6 +107,16 @@ describe('SettingsFanoutSection', () => {
});
});
it('hides bot option from add integration menu when bots_disabled', async () => {
renderSection({ health: { ...baseHealth, bots_disabled: true } });
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
expect(screen.queryByRole('menuitem', { name: 'Bot' })).not.toBeInTheDocument();
});
it('lists existing configs after load', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
@@ -277,31 +288,156 @@ describe('SettingsFanoutSection', () => {
});
it('navigates to create view when clicking add button', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
await waitFor(() => {
expect(screen.getByText('← Back to list')).toBeInTheDocument();
expect(screen.getByLabelText('Name')).toHaveValue('Webhook #1');
// Should show the URL input for webhook type
expect(screen.getByLabelText(/URL/)).toBeInTheDocument();
});
expect(mockedApi.createFanoutConfig).not.toHaveBeenCalled();
});
it('backing out of a new draft does not create an integration', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.click(screen.getByText('← Back to list'));
await waitFor(() => expect(screen.queryByText('← Back to list')).not.toBeInTheDocument());
expect(mockedApi.createFanoutConfig).not.toHaveBeenCalled();
});
it('back to list asks for confirmation before leaving', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.click(screen.getByText('← Back to list'));
expect(window.confirm).toHaveBeenCalledWith('Leave without saving?');
await waitFor(() => expect(screen.queryByText('← Back to list')).not.toBeInTheDocument());
});
it('back to list stays on the edit screen when confirmation is cancelled', async () => {
vi.mocked(window.confirm).mockReturnValue(false);
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.click(screen.getByText('← Back to list'));
expect(window.confirm).toHaveBeenCalledWith('Leave without saving?');
expect(screen.getByText('← Back to list')).toBeInTheDocument();
});
it('saving a new draft creates the integration on demand', async () => {
const createdWebhook: FanoutConfig = {
id: 'wh-new',
type: 'webhook',
name: 'Webhook',
name: 'Webhook #1',
enabled: false,
config: { url: '', method: 'POST', headers: {} },
config: { url: '', method: 'POST', headers: {}, hmac_secret: '', hmac_header: '' },
scope: { messages: 'all', raw_packets: 'none' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdWebhook);
// After creation, getFanoutConfigs returns the new config
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdWebhook]);
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Webhook' })).toBeInTheDocument();
});
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Webhook' }));
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
await waitFor(() => {
expect(screen.getByText('← Back to list')).toBeInTheDocument();
// Should show the URL input for webhook type
expect(screen.getByLabelText(/URL/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Save as Disabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'webhook',
name: 'Webhook #1',
config: { url: '', method: 'POST', headers: {}, hmac_secret: '', hmac_header: '' },
scope: { messages: 'all', raw_packets: 'none' },
enabled: false,
})
);
});
it('new draft names increment within the integration type', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([
webhookConfig,
{
...webhookConfig,
id: 'wh-2',
name: 'Another Hook',
},
]);
renderSection();
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('Webhook #3'));
});
it('clicking a list name allows inline rename and saves on blur', async () => {
const renamedWebhook = { ...webhookConfig, name: 'Renamed Hook' };
mockedApi.getFanoutConfigs
.mockResolvedValueOnce([webhookConfig])
.mockResolvedValueOnce([renamedWebhook]);
mockedApi.updateFanoutConfig.mockResolvedValue(renamedWebhook);
renderSection();
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Test Hook' }));
const inlineInput = screen.getByLabelText('Edit name for Test Hook');
fireEvent.change(inlineInput, { target: { value: 'Renamed Hook' } });
fireEvent.blur(inlineInput);
await waitFor(() =>
expect(mockedApi.updateFanoutConfig).toHaveBeenCalledWith('wh-1', { name: 'Renamed Hook' })
);
await waitFor(() => expect(screen.getByText('Renamed Hook')).toBeInTheDocument());
});
it('escape cancels inline rename without saving', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Test Hook' }));
const inlineInput = screen.getByLabelText('Edit name for Test Hook');
fireEvent.change(inlineInput, { target: { value: 'Cancelled Hook' } });
fireEvent.keyDown(inlineInput, { key: 'Escape' });
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
expect(mockedApi.updateFanoutConfig).not.toHaveBeenCalledWith('wh-1', {
name: 'Cancelled Hook',
});
});
@@ -309,7 +445,7 @@ describe('SettingsFanoutSection', () => {
const communityConfig: FanoutConfig = {
id: 'comm-1',
type: 'mqtt_community',
name: 'Community MQTT/mesh2mqtt',
name: 'Community Feed',
enabled: false,
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
@@ -329,7 +465,7 @@ describe('SettingsFanoutSection', () => {
};
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
renderSection();
await waitFor(() => expect(screen.getByText('Community MQTT/mesh2mqtt')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Community Feed')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
@@ -379,7 +515,7 @@ describe('SettingsFanoutSection', () => {
const communityConfig: FanoutConfig = {
id: 'comm-1',
type: 'mqtt_community',
name: 'Community MQTT/mesh2mqtt',
name: 'Community Feed',
enabled: false,
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
@@ -399,7 +535,7 @@ describe('SettingsFanoutSection', () => {
};
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
renderSection();
await waitFor(() => expect(screen.getByText('Community MQTT/mesh2mqtt')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Community Feed')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
@@ -414,7 +550,7 @@ describe('SettingsFanoutSection', () => {
const communityConfig: FanoutConfig = {
id: 'comm-1',
type: 'mqtt_community',
name: 'Community MQTT/mesh2mqtt',
name: 'Community Feed',
enabled: false,
config: {
broker_host: 'meshrank.net',
@@ -432,7 +568,7 @@ describe('SettingsFanoutSection', () => {
};
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
renderSection();
await waitFor(() => expect(screen.getByText('Community MQTT/mesh2mqtt')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('Community Feed')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
@@ -445,7 +581,7 @@ describe('SettingsFanoutSection', () => {
const communityConfig: FanoutConfig = {
id: 'comm-1',
type: 'mqtt_community',
name: 'Community MQTT/mesh2mqtt',
name: 'Community Feed',
enabled: false,
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
@@ -477,7 +613,7 @@ describe('SettingsFanoutSection', () => {
const privateConfig: FanoutConfig = {
id: 'mqtt-1',
type: 'mqtt_private',
name: 'Private MQTT',
name: 'Private Broker',
enabled: true,
config: { broker_host: 'broker.local', broker_port: 1883, topic_prefix: 'meshcore' },
scope: { messages: 'all', raw_packets: 'all' },
@@ -497,7 +633,7 @@ describe('SettingsFanoutSection', () => {
const config: FanoutConfig = {
id: 'wh-1',
type: 'webhook',
name: 'Webhook',
name: 'Webhook Feed',
enabled: true,
config: { url: 'https://example.com/hook', method: 'POST', headers: {} },
scope: { messages: 'all', raw_packets: 'none' },
@@ -514,7 +650,7 @@ describe('SettingsFanoutSection', () => {
const config: FanoutConfig = {
id: 'ap-1',
type: 'apprise',
name: 'Apprise',
name: 'Apprise Feed',
enabled: true,
config: {
urls: 'discord://abc\nmailto://one@example.com\nmailto://two@example.com',
@@ -532,4 +668,44 @@ describe('SettingsFanoutSection', () => {
expect(screen.getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)).toBeInTheDocument()
);
});
it('groups integrations by type and sorts entries alphabetically within each group', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([
{
...webhookConfig,
id: 'wh-b',
name: 'Zulu Hook',
},
{
...webhookConfig,
id: 'wh-a',
name: 'Alpha Hook',
},
{
id: 'ap-1',
type: 'apprise',
name: 'Bravo Alerts',
enabled: true,
config: { urls: 'discord://abc', preserve_identity: true, include_path: true },
scope: { messages: 'all', raw_packets: 'none' },
sort_order: 0,
created_at: 1000,
},
]);
renderSection();
const webhookGroup = await screen.findByRole('region', { name: 'Webhook integrations' });
const appriseGroup = screen.getByRole('region', { name: 'Apprise integrations' });
expect(
screen.queryByRole('region', { name: 'Private MQTT integrations' })
).not.toBeInTheDocument();
expect(within(webhookGroup).getByText('Alpha Hook')).toBeInTheDocument();
expect(within(webhookGroup).getByText('Zulu Hook')).toBeInTheDocument();
expect(within(appriseGroup).getByText('Bravo Alerts')).toBeInTheDocument();
const alpha = within(webhookGroup).getByText('Alpha Hook');
const zulu = within(webhookGroup).getByText('Zulu Hook');
expect(alpha.compareDocumentPosition(zulu) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
+10
View File
@@ -228,6 +228,16 @@ describe('SettingsModal', () => {
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
});
it('applies the centered 800px column layout to non-fanout settings content', () => {
renderModal({
externalSidebarNav: true,
desktopSection: 'local',
});
const localSettingsText = screen.getByText('These settings apply only to this device/browser.');
expect(localSettingsText.closest('div')).toHaveClass('mx-auto', 'w-full', 'max-w-[800px]');
});
it('toggles sections in mobile accordion mode', () => {
renderModal({ mobile: true });
const localToggle = screen.getAllByRole('button', { name: /Local Configuration/i })[0];