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

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'

View File

@@ -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>

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 = {

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();
});
});

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];

View File

@@ -0,0 +1,48 @@
import type { Locator, Page } from '@playwright/test';
import http from 'http';
export function createCaptureServer(urlFactory: (port: number) => string) {
const requests: { body: string; headers: http.IncomingHttpHeaders }[] = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
requests.push({ body, headers: req.headers });
res.writeHead(200);
res.end('ok');
});
});
return {
requests,
server,
async listen(): Promise<string> {
return await new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (typeof addr === 'object' && addr) {
resolve(urlFactory(addr.port));
}
});
});
},
close(): void {
server.close();
},
};
}
export async function openFanoutSettings(page: Page): Promise<void> {
await page.goto('/');
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
}
export function fanoutHeader(page: Page, name: string): Locator {
const nameButton = page.getByRole('button', { name, exact: true });
return page
.locator('div')
.filter({ has: nameButton })
.filter({ has: page.getByRole('button', { name: 'Edit' }) })
.last();
}

View File

@@ -4,9 +4,21 @@ import {
deleteFanoutConfig,
getFanoutConfigs,
} from '../helpers/api';
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
test.describe('Apprise integration settings', () => {
let createdAppriseId: string | null = null;
let receiver: ReturnType<typeof createCaptureServer>;
let appriseUrl: string;
test.beforeAll(async () => {
receiver = createCaptureServer((port) => `json://127.0.0.1:${port}`);
appriseUrl = await receiver.listen();
});
test.afterAll(async () => {
receiver.close();
});
test.afterEach(async () => {
if (createdAppriseId) {
@@ -20,22 +32,19 @@ test.describe('Apprise integration settings', () => {
});
test('create apprise via UI, configure URLs, save as enabled', async ({ page }) => {
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
// Open settings and navigate to MQTT & Automation
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Open add menu and pick Apprise
await page.getByRole('button', { name: 'Add Integration' }).click();
await page.getByRole('menuitem', { name: 'Apprise' }).click();
// Click the Apprise add button
await page.getByRole('button', { name: 'Apprise' }).click();
// Should navigate to the detail/edit view with default name
await expect(page.locator('#fanout-edit-name')).toHaveValue('Apprise');
// Should navigate to the detail/edit view with a numbered default name
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Apprise #\d+/);
// Fill in notification URL
const urlsTextarea = page.locator('#fanout-apprise-urls');
await urlsTextarea.fill('json://localhost:9999');
await urlsTextarea.fill(appriseUrl);
// Verify preserve identity checkbox is checked by default
const preserveIdentity = page.getByText('Preserve identity on Discord');
@@ -56,6 +65,7 @@ test.describe('Apprise integration settings', () => {
// Should be back on list view with our apprise config visible
await expect(page.getByText('E2E Apprise')).toBeVisible();
await expect(page.getByText(appriseUrl)).toBeVisible();
// Clean up via API
const configs = await getFanoutConfigs();
@@ -70,7 +80,7 @@ test.describe('Apprise integration settings', () => {
type: 'apprise',
name: 'API Apprise',
config: {
urls: 'json://localhost:9999\nslack://token_a/token_b/token_c',
urls: `${appriseUrl}\nslack://token_a/token_b/token_c`,
preserve_identity: false,
include_path: false,
},
@@ -78,19 +88,17 @@ test.describe('Apprise integration settings', () => {
});
createdAppriseId = apprise.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit on our apprise config
const row = page.getByText('API Apprise').locator('..');
const row = fanoutHeader(page, 'API Apprise');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Verify the URLs textarea has our content
const urlsTextarea = page.locator('#fanout-apprise-urls');
await expect(urlsTextarea).toHaveValue(/json:\/\/localhost:9999/);
await expect(urlsTextarea).toHaveValue(new RegExp(appriseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
await expect(urlsTextarea).toHaveValue(/slack:\/\/token_a/);
// Verify checkboxes reflect our config (both unchecked)
@@ -107,24 +115,24 @@ test.describe('Apprise integration settings', () => {
await expect(pathCheckbox).not.toBeChecked();
// Go back
page.once('dialog', (dialog) => dialog.accept());
await page.getByText('← Back to list').click();
await expect(row).toBeVisible();
});
test('apprise shows scope selector', async ({ page }) => {
const apprise = await createFanoutConfig({
type: 'apprise',
name: 'Scope Apprise',
config: { urls: 'json://localhost:9999' },
config: { urls: appriseUrl },
});
createdAppriseId = apprise.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
const row = page.getByText('Scope Apprise').locator('..');
const row = fanoutHeader(page, 'Scope Apprise');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Verify scope selector is present
@@ -138,31 +146,31 @@ test.describe('Apprise integration settings', () => {
await expect(page.getByText('Channels (exclude)')).toBeVisible();
// Go back
page.once('dialog', (dialog) => dialog.accept());
await page.getByText('← Back to list').click();
await expect(row).toBeVisible();
});
test('apprise disabled config shows amber dot and can be enabled via save button', async ({
test('apprise disabled config shows disabled status and can be enabled via save button', async ({
page,
}) => {
const apprise = await createFanoutConfig({
type: 'apprise',
name: 'Disabled Apprise',
config: { urls: 'json://localhost:9999' },
config: { urls: appriseUrl },
enabled: false,
});
createdAppriseId = apprise.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Should show "Disabled" status text
const row = page.getByText('Disabled Apprise').locator('..');
await expect(row.getByText('Disabled', { exact: true })).toBeVisible();
const row = fanoutHeader(page, 'Disabled Apprise');
await expect(row).toContainText('Disabled');
// Edit it
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Save as enabled
@@ -179,27 +187,24 @@ test.describe('Apprise integration settings', () => {
const apprise = await createFanoutConfig({
type: 'apprise',
name: 'Delete Me Apprise',
config: { urls: 'json://localhost:9999' },
config: { urls: appriseUrl },
});
createdAppriseId = apprise.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
const row = page.getByText('Delete Me Apprise').locator('..');
const row = fanoutHeader(page, 'Delete Me Apprise');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Accept the confirmation dialog
page.on('dialog', (dialog) => dialog.accept());
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Integration deleted')).toBeVisible();
// Should be back on list, apprise gone
await expect(page.getByText('Delete Me Apprise')).not.toBeVisible();
await expect(row).not.toBeVisible();
createdAppriseId = null;
});
});

View File

@@ -4,9 +4,21 @@ import {
deleteFanoutConfig,
getFanoutConfigs,
} from '../helpers/api';
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
test.describe('Webhook integration settings', () => {
let createdWebhookId: string | null = null;
let receiver: ReturnType<typeof createCaptureServer>;
let webhookUrl: string;
test.beforeAll(async () => {
receiver = createCaptureServer((port) => `http://127.0.0.1:${port}`);
webhookUrl = await receiver.listen();
});
test.afterAll(async () => {
receiver.close();
});
test.afterEach(async () => {
if (createdWebhookId) {
@@ -20,22 +32,19 @@ test.describe('Webhook integration settings', () => {
});
test('create webhook via UI, configure, save as enabled, verify in list', async ({ page }) => {
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
// Open settings and navigate to MQTT & Automation
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Open add menu and pick Webhook
await page.getByRole('button', { name: 'Add Integration' }).click();
await page.getByRole('menuitem', { name: 'Webhook' }).click();
// Click the Webhook add button
await page.getByRole('button', { name: 'Webhook' }).click();
// Should navigate to the detail/edit view with default name
await expect(page.locator('#fanout-edit-name')).toHaveValue('Webhook');
// Should navigate to the detail/edit view with a numbered default name
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
// Fill in webhook URL
const urlInput = page.locator('#fanout-webhook-url');
await urlInput.fill('https://example.com/e2e-test-hook');
await urlInput.fill(webhookUrl);
// Verify method defaults to POST
await expect(page.locator('#fanout-webhook-method')).toHaveValue('POST');
@@ -51,6 +60,7 @@ test.describe('Webhook integration settings', () => {
// Should be back on list view with our webhook visible
await expect(page.getByText('E2E Webhook')).toBeVisible();
await expect(page.getByText(webhookUrl)).toBeVisible();
// Clean up via API
const configs = await getFanoutConfigs();
@@ -60,24 +70,46 @@ test.describe('Webhook integration settings', () => {
}
});
test('leaving a new webhook draft does not create a persisted config', async ({ page }) => {
const existingConfigs = await getFanoutConfigs();
const existingIds = new Set(existingConfigs.map((cfg) => cfg.id));
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByRole('button', { name: 'Add Integration' }).click();
await page.getByRole('menuitem', { name: 'Webhook' }).click();
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
await page.locator('#fanout-edit-name').fill('Unsaved Webhook Draft');
await page.locator('#fanout-webhook-url').fill(webhookUrl);
page.once('dialog', (dialog) => dialog.accept());
await page.getByText('← Back to list').click();
await expect(page.getByText('Unsaved Webhook Draft')).not.toBeVisible();
const updatedConfigs = await getFanoutConfigs();
const newConfigs = updatedConfigs.filter((cfg) => !existingIds.has(cfg.id));
expect(newConfigs).toHaveLength(0);
expect(updatedConfigs.find((cfg) => cfg.name === 'Unsaved Webhook Draft')).toBeUndefined();
});
test('create webhook via API, edit in UI, save as disabled', async ({ page }) => {
// Create via API
const webhook = await createFanoutConfig({
type: 'webhook',
name: 'API Webhook',
config: { url: 'https://example.com/hook', method: 'POST', headers: {} },
config: { url: webhookUrl, method: 'POST', headers: {} },
enabled: true,
});
createdWebhookId = webhook.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit on our webhook
const row = page.getByText('API Webhook').locator('..');
const row = fanoutHeader(page, 'API Webhook');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Should be in edit view
@@ -88,7 +120,8 @@ test.describe('Webhook integration settings', () => {
// Save as disabled
await page.getByRole('button', { name: /Save as Disabled/i }).click();
await expect(page.getByText('Integration saved')).toBeVisible();
await expect(page.locator('#fanout-edit-name')).not.toBeVisible();
await expect(row).toContainText('Disabled');
// Verify it's now disabled in the list
const configs = await getFanoutConfigs();
@@ -101,18 +134,16 @@ test.describe('Webhook integration settings', () => {
const webhook = await createFanoutConfig({
type: 'webhook',
name: 'Scope Webhook',
config: { url: 'https://example.com/hook', method: 'POST', headers: {} },
config: { url: webhookUrl, method: 'POST', headers: {} },
});
createdWebhookId = webhook.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit
const row = page.getByText('Scope Webhook').locator('..');
const row = fanoutHeader(page, 'Scope Webhook');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Verify scope selector is visible with the three webhook-applicable modes
@@ -128,37 +159,35 @@ test.describe('Webhook integration settings', () => {
await expect(page.getByText('Channels (include)')).toBeVisible();
// Go back without saving
page.once('dialog', (dialog) => dialog.accept());
await page.getByText('← Back to list').click();
await expect(row).toBeVisible();
});
test('delete webhook via UI', async ({ page }) => {
const webhook = await createFanoutConfig({
type: 'webhook',
name: 'Delete Me Webhook',
config: { url: 'https://example.com/hook', method: 'POST', headers: {} },
config: { url: webhookUrl, method: 'POST', headers: {} },
});
createdWebhookId = webhook.id;
await page.goto('/');
await openFanoutSettings(page);
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Settings').click();
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
// Click Edit
const row = page.getByText('Delete Me Webhook').locator('..');
const row = fanoutHeader(page, 'Delete Me Webhook');
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Edit' }).click();
// Accept the confirmation dialog
page.on('dialog', (dialog) => dialog.accept());
page.once('dialog', (dialog) => dialog.accept());
// Click Delete
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Integration deleted')).toBeVisible();
// Should be back on list, webhook gone
await expect(page.getByText('Delete Me Webhook')).not.toBeVisible();
await expect(row).not.toBeVisible();
// Already deleted, clear the cleanup reference
createdWebhookId = null;