mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Fanout integration UX overhaul
This commit is contained in:
@@ -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}
|
||||
>
|
||||
← 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>
|
||||
|
||||
@@ -185,6 +185,9 @@ const baseSettings = {
|
||||
preferences_migrated: false,
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
};
|
||||
|
||||
const publicChannel = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
48
tests/e2e/helpers/fanout.ts
Normal file
48
tests/e2e/helpers/fanout.ts
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user