mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-03 03:53:10 +02:00
Make a better integration/fanout selector
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api';
|
||||
@@ -15,21 +17,12 @@ const BotCodeEditor = lazy(() =>
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
mqtt_private: 'Private MQTT',
|
||||
mqtt_community: 'Community MQTT',
|
||||
bot: 'Bot',
|
||||
bot: 'Python Bot',
|
||||
webhook: 'Webhook',
|
||||
apprise: 'Apprise',
|
||||
sqs: 'Amazon SQS',
|
||||
};
|
||||
|
||||
const LIST_TYPE_OPTIONS = [
|
||||
{ value: 'mqtt_private', label: 'Private MQTT' },
|
||||
{ value: 'mqtt_community', label: 'Community MQTT' },
|
||||
{ value: 'bot', label: 'Bot' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'apprise', label: 'Apprise' },
|
||||
{ value: 'sqs', label: 'Amazon SQS' },
|
||||
];
|
||||
|
||||
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
|
||||
const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net';
|
||||
const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net';
|
||||
@@ -42,30 +35,6 @@ const DEFAULT_MESHRANK_TRANSPORT = 'tcp';
|
||||
const DEFAULT_MESHRANK_AUTH_MODE = 'none';
|
||||
const DEFAULT_MESHRANK_IATA = 'XYZ';
|
||||
|
||||
const CREATE_TYPE_OPTIONS = [
|
||||
{ value: 'mqtt_private', label: 'Private MQTT' },
|
||||
{ value: 'mqtt_community_meshrank', label: 'MeshRank' },
|
||||
{ value: 'mqtt_community_letsmesh_us', label: 'LetsMesh (US)' },
|
||||
{ value: 'mqtt_community_letsmesh_eu', label: 'LetsMesh (EU)' },
|
||||
{ value: 'mqtt_community', label: 'Community MQTT/meshcoretomqtt' },
|
||||
{ value: 'bot', label: 'Bot' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'apprise', label: 'Apprise' },
|
||||
{ value: 'sqs', label: 'Amazon SQS' },
|
||||
] as const;
|
||||
|
||||
type DraftType = (typeof CREATE_TYPE_OPTIONS)[number]['value'];
|
||||
|
||||
type DraftRecipe = {
|
||||
savedType: string;
|
||||
detailLabel: string;
|
||||
defaultName: string;
|
||||
defaults: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
function createCommunityConfigDefaults(
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Record<string, unknown> {
|
||||
@@ -122,11 +91,41 @@ const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
|
||||
return "[BOT] Plong!"
|
||||
return None`;
|
||||
|
||||
const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
mqtt_private: {
|
||||
type DraftType =
|
||||
| 'mqtt_private'
|
||||
| 'mqtt_community'
|
||||
| 'mqtt_community_meshrank'
|
||||
| 'mqtt_community_letsmesh_us'
|
||||
| 'mqtt_community_letsmesh_eu'
|
||||
| 'webhook'
|
||||
| 'apprise'
|
||||
| 'sqs'
|
||||
| 'bot';
|
||||
|
||||
type CreateIntegrationDefinition = {
|
||||
value: DraftType;
|
||||
savedType: string;
|
||||
label: string;
|
||||
section: string;
|
||||
description: string;
|
||||
defaultName: string;
|
||||
nameMode: 'counted' | 'fixed';
|
||||
defaults: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
{
|
||||
value: 'mqtt_private',
|
||||
savedType: 'mqtt_private',
|
||||
detailLabel: 'Private MQTT',
|
||||
label: 'Private MQTT',
|
||||
section: 'Bulk Forwarding',
|
||||
description:
|
||||
'Customizable-scope forwarding of all or some messages to an MQTT broker of your choosing, in raw and/or decrypted form.',
|
||||
defaultName: 'Private MQTT',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
broker_host: '',
|
||||
@@ -140,10 +139,29 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_meshrank: {
|
||||
{
|
||||
value: 'mqtt_community',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'MeshRank',
|
||||
label: 'Community MQTT/meshcoretomqtt',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.',
|
||||
defaultName: 'Community MQTT',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults(),
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'mqtt_community_meshrank',
|
||||
savedType: 'mqtt_community',
|
||||
label: 'MeshRank',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'MeshRank',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
||||
@@ -158,10 +176,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_letsmesh_us: {
|
||||
{
|
||||
value: 'mqtt_community_letsmesh_us',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'LetsMesh (US)',
|
||||
label: 'LetsMesh (US)',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'LetsMesh (US)',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
|
||||
@@ -170,10 +193,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_letsmesh_eu: {
|
||||
{
|
||||
value: 'mqtt_community_letsmesh_eu',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'LetsMesh (EU)',
|
||||
label: 'LetsMesh (EU)',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'LetsMesh (EU)',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU,
|
||||
@@ -182,30 +210,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community: {
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'Community MQTT/meshcoretomqtt',
|
||||
defaultName: 'Community MQTT',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults(),
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
bot: {
|
||||
savedType: 'bot',
|
||||
detailLabel: 'Bot',
|
||||
defaultName: 'Bot',
|
||||
defaults: {
|
||||
config: {
|
||||
code: DEFAULT_BOT_CODE,
|
||||
},
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
webhook: {
|
||||
{
|
||||
value: 'webhook',
|
||||
savedType: 'webhook',
|
||||
detailLabel: 'Webhook',
|
||||
label: 'Webhook',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'Generic webhook for decrypted channel/DM messages with customizable verb, method, and optional HMAC signature.',
|
||||
defaultName: 'Webhook',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
url: '',
|
||||
@@ -217,10 +230,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
apprise: {
|
||||
{
|
||||
value: 'apprise',
|
||||
savedType: 'apprise',
|
||||
detailLabel: 'Apprise',
|
||||
label: 'Apprise',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'A wide-ranging generic fanout, capable of forwarding decrypted channel/DM messages to Discord, Telegram, email, SMS, and many others.',
|
||||
defaultName: 'Apprise',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
urls: '',
|
||||
@@ -230,10 +248,14 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
sqs: {
|
||||
{
|
||||
value: 'sqs',
|
||||
savedType: 'sqs',
|
||||
detailLabel: 'Amazon SQS',
|
||||
label: 'Amazon SQS',
|
||||
section: 'Bulk Forwarding',
|
||||
description: 'Send full or scope-customized raw or decrypted packets to an SQS',
|
||||
defaultName: 'Amazon SQS',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
queue_url: '',
|
||||
@@ -246,15 +268,41 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
};
|
||||
{
|
||||
value: 'bot',
|
||||
savedType: 'bot',
|
||||
label: 'Python Bot',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'A simple, Python-based interface for basic bots that can respond to DM and channel messages.',
|
||||
defaultName: 'Bot',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
code: DEFAULT_BOT_CODE,
|
||||
},
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
|
||||
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
|
||||
) as Record<DraftType, CreateIntegrationDefinition>;
|
||||
|
||||
function isDraftType(value: string): value is DraftType {
|
||||
return value in DRAFT_RECIPES;
|
||||
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
|
||||
}
|
||||
|
||||
function getCreateIntegrationDefinition(draftType: DraftType) {
|
||||
return CREATE_INTEGRATION_DEFINITIONS_BY_VALUE[draftType];
|
||||
}
|
||||
|
||||
function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) {
|
||||
const recipe = DRAFT_RECIPES[draftType];
|
||||
return name || getDefaultIntegrationName(recipe.savedType, configs);
|
||||
const definition = getCreateIntegrationDefinition(draftType);
|
||||
if (name) return name;
|
||||
if (definition.nameMode === 'fixed') return definition.defaultName;
|
||||
return getDefaultIntegrationName(definition.savedType, configs);
|
||||
}
|
||||
|
||||
function normalizeDraftConfig(draftType: DraftType, config: Record<string, unknown>) {
|
||||
@@ -305,22 +353,160 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
}
|
||||
|
||||
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
||||
if (draftType.startsWith('mqtt_community_')) {
|
||||
if (getCreateIntegrationDefinition(draftType).savedType === 'mqtt_community') {
|
||||
return { messages: 'none', raw_packets: 'all' };
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
function cloneDraftDefaults(draftType: DraftType) {
|
||||
const recipe = DRAFT_RECIPES[draftType];
|
||||
const recipe = getCreateIntegrationDefinition(draftType);
|
||||
return {
|
||||
config: structuredClone(recipe.defaults.config),
|
||||
scope: structuredClone(recipe.defaults.scope),
|
||||
};
|
||||
}
|
||||
|
||||
function CreateIntegrationDialog({
|
||||
open,
|
||||
options,
|
||||
selectedType,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
options: readonly CreateIntegrationDefinition[];
|
||||
selectedType: DraftType | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (type: DraftType) => void;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
const selectedOption =
|
||||
options.find((option) => option.value === selectedType) ?? options[0] ?? null;
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||
|
||||
const updateScrollHint = useCallback(() => {
|
||||
const container = listRef.current;
|
||||
if (!container) {
|
||||
setShowScrollHint(false);
|
||||
return;
|
||||
}
|
||||
setShowScrollHint(container.scrollTop + container.clientHeight < container.scrollHeight - 8);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const frame = window.requestAnimationFrame(updateScrollHint);
|
||||
window.addEventListener('resize', updateScrollHint);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', updateScrollHint);
|
||||
};
|
||||
}, [open, options, updateScrollHint]);
|
||||
|
||||
const sectionedOptions = [...new Set(options.map((o) => o.section))]
|
||||
.map((section) => ({
|
||||
section,
|
||||
options: options.filter((option) => option.section === section),
|
||||
}))
|
||||
.filter((group) => group.options.length > 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
hideCloseButton
|
||||
className="flex max-h-[calc(100dvh-2rem)] w-[96vw] max-w-[960px] flex-col overflow-hidden p-0 sm:rounded-xl"
|
||||
>
|
||||
<DialogHeader className="border-b border-border px-5 py-4">
|
||||
<DialogTitle>Create Integration</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden md:grid-cols-[240px_minmax(0,1fr)]">
|
||||
<div className="relative border-b border-border bg-muted/20 md:border-b-0 md:border-r">
|
||||
<div
|
||||
ref={listRef}
|
||||
onScroll={updateScrollHint}
|
||||
className="max-h-56 overflow-y-auto p-2 md:max-h-[420px]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{sectionedOptions.map((group) => (
|
||||
<div key={group.section} className="space-y-1.5">
|
||||
<div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{group.section}
|
||||
</div>
|
||||
{group.options.map((option) => {
|
||||
const selected = option.value === selectedOption?.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-left transition-colors',
|
||||
selected
|
||||
? 'border-primary bg-accent text-foreground'
|
||||
: 'border-transparent bg-transparent hover:bg-accent/70'
|
||||
)}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(option.value)}
|
||||
>
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showScrollHint && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-gradient-to-t from-background via-background/85 to-transparent px-4 pb-2 pt-8">
|
||||
<div className="rounded-full border border-border/80 bg-background/95 px-2 py-1 text-muted-foreground shadow-sm">
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5 md:min-h-[280px] md:max-h-[420px]">
|
||||
{selectedOption ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{selectedOption.section}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{selectedOption.description}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No integration types are currently available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 border-t border-border px-5 py-4 sm:justify-end">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={onCreate} disabled={!selectedOption}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function getDetailTypeLabel(detailType: string) {
|
||||
if (isDraftType(detailType)) return DRAFT_RECIPES[detailType].detailLabel;
|
||||
if (isDraftType(detailType)) return getCreateIntegrationDefinition(detailType).label;
|
||||
return TYPE_LABELS[detailType] || detailType;
|
||||
}
|
||||
|
||||
@@ -1499,9 +1685,9 @@ export function SettingsFanoutSection({
|
||||
const [editName, setEditName] = useState('');
|
||||
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
||||
const [inlineEditName, setInlineEditName] = useState('');
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const addMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const loadConfigs = useCallback(async () => {
|
||||
try {
|
||||
@@ -1516,18 +1702,28 @@ export function SettingsFanoutSection({
|
||||
loadConfigs();
|
||||
}, [loadConfigs]);
|
||||
|
||||
const availableCreateOptions = useMemo(
|
||||
() =>
|
||||
CREATE_INTEGRATION_DEFINITIONS.filter(
|
||||
(definition) => definition.savedType !== 'bot' || !health?.bots_disabled
|
||||
),
|
||||
[health?.bots_disabled]
|
||||
);
|
||||
|
||||
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]);
|
||||
if (!createDialogOpen) return;
|
||||
if (availableCreateOptions.length === 0) {
|
||||
setSelectedCreateType(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedCreateType &&
|
||||
availableCreateOptions.some((option) => option.value === selectedCreateType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSelectedCreateType(availableCreateOptions[0].value);
|
||||
}, [createDialogOpen, availableCreateOptions, selectedCreateType]);
|
||||
|
||||
const handleToggleEnabled = async (cfg: FanoutConfig) => {
|
||||
try {
|
||||
@@ -1541,7 +1737,7 @@ export function SettingsFanoutSection({
|
||||
};
|
||||
|
||||
const handleEdit = (cfg: FanoutConfig) => {
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setInlineEditingId(null);
|
||||
setInlineEditName('');
|
||||
setDraftType(null);
|
||||
@@ -1552,7 +1748,7 @@ export function SettingsFanoutSection({
|
||||
};
|
||||
|
||||
const handleStartInlineEdit = (cfg: FanoutConfig) => {
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setInlineEditingId(cfg.id);
|
||||
setInlineEditName(cfg.name);
|
||||
};
|
||||
@@ -1611,7 +1807,7 @@ export function SettingsFanoutSection({
|
||||
setBusy(true);
|
||||
try {
|
||||
if (currentDraftType) {
|
||||
const recipe = DRAFT_RECIPES[currentDraftType];
|
||||
const recipe = getCreateIntegrationDefinition(currentDraftType);
|
||||
await api.createFanoutConfig({
|
||||
type: recipe.savedType,
|
||||
name: normalizeDraftName(currentDraftType, editName.trim(), configs),
|
||||
@@ -1663,18 +1859,16 @@ export function SettingsFanoutSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCreate = async (type: string) => {
|
||||
if (!isDraftType(type)) return;
|
||||
const handleAddCreate = (type: DraftType) => {
|
||||
const definition = getCreateIntegrationDefinition(type);
|
||||
const defaults = cloneDraftDefaults(type);
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setEditingId(null);
|
||||
setDraftType(type);
|
||||
setEditName(
|
||||
type === 'mqtt_community_meshrank' ||
|
||||
type === 'mqtt_community_letsmesh_us' ||
|
||||
type === 'mqtt_community_letsmesh_eu'
|
||||
? DRAFT_RECIPES[type].defaultName
|
||||
: getDefaultIntegrationName(DRAFT_RECIPES[type].savedType, configs)
|
||||
definition.nameMode === 'fixed'
|
||||
? definition.defaultName
|
||||
: getDefaultIntegrationName(definition.savedType, configs)
|
||||
);
|
||||
setEditConfig(defaults.config);
|
||||
setEditScope(defaults.scope);
|
||||
@@ -1683,13 +1877,15 @@ export function SettingsFanoutSection({
|
||||
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
|
||||
const detailType = draftType ?? editingConfig?.type ?? null;
|
||||
const isDraft = draftType !== null;
|
||||
const configGroups = LIST_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);
|
||||
const configGroups = Object.entries(TYPE_LABELS)
|
||||
.map(([type, label]) => ({
|
||||
type,
|
||||
label,
|
||||
configs: configs
|
||||
.filter((cfg) => cfg.type === type)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
|
||||
}))
|
||||
.filter((group) => group.configs.length > 0);
|
||||
|
||||
// Detail view
|
||||
if (detailType) {
|
||||
@@ -1823,37 +2019,22 @@ export function SettingsFanoutSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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-72 rounded-md border border-input bg-background p-1 shadow-md"
|
||||
>
|
||||
{CREATE_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>
|
||||
<Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
Add Integration
|
||||
</Button>
|
||||
|
||||
<CreateIntegrationDialog
|
||||
open={createDialogOpen}
|
||||
options={availableCreateOptions}
|
||||
selectedType={selectedCreateType}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSelect={setSelectedCreateType}
|
||||
onCreate={() => {
|
||||
if (selectedCreateType) {
|
||||
handleAddCreate(selectedCreateType);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{configGroups.length > 0 && (
|
||||
<div className="columns-1 gap-4 md:columns-2">
|
||||
|
||||
@@ -67,6 +67,29 @@ function renderSectionWithRefresh(
|
||||
);
|
||||
}
|
||||
|
||||
function startsWithAccessibleName(name: string) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^${escaped}(?:\\s|$)`);
|
||||
}
|
||||
|
||||
async function openCreateIntegrationDialog() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
return screen.findByRole('dialog', { name: 'Create Integration' });
|
||||
}
|
||||
|
||||
function selectCreateIntegration(name: string) {
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: startsWithAccessibleName(name) }));
|
||||
}
|
||||
|
||||
function confirmCreateIntegration() {
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
@@ -76,35 +99,64 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('SettingsFanoutSection', () => {
|
||||
it('shows add integration menu with all integration types', async () => {
|
||||
it('shows add integration dialog with all integration types', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'Private MQTT' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'MeshRank' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'LetsMesh (US)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' })).toBeInTheDocument();
|
||||
const optionButtons = within(dialog)
|
||||
.getAllByRole('button')
|
||||
.filter((button) => button.hasAttribute('aria-pressed'));
|
||||
expect(optionButtons).toHaveLength(9);
|
||||
expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' })
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Private MQTT') })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Amazon SQS' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('MeshRank') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (US)') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (EU)') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: startsWithAccessibleName('Community MQTT/meshcoretomqtt'),
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Webhook') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Apprise') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Amazon SQS') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
|
||||
const genericCommunityIndex = optionButtons.findIndex((button) =>
|
||||
button.textContent?.startsWith('Community MQTT/meshcoretomqtt')
|
||||
);
|
||||
const meshRankIndex = optionButtons.findIndex((button) =>
|
||||
button.textContent?.startsWith('MeshRank')
|
||||
);
|
||||
expect(genericCommunityIndex).toBeGreaterThan(-1);
|
||||
expect(meshRankIndex).toBeGreaterThan(-1);
|
||||
expect(genericCommunityIndex).toBeLessThan(meshRankIndex);
|
||||
});
|
||||
|
||||
it('shows bot option in add integration menu when bots are enabled', async () => {
|
||||
it('shows bot option in add integration dialog when bots are enabled', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows bots disabled banner when bots_disabled', async () => {
|
||||
@@ -123,14 +175,12 @@ describe('SettingsFanoutSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('hides bot option from add integration menu when bots_disabled', async () => {
|
||||
it('hides bot option from add integration dialog 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();
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists existing configs after load', async () => {
|
||||
@@ -305,12 +355,9 @@ 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 openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
||||
@@ -324,12 +371,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('new SQS draft shows queue url fields and sensible defaults', 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: 'Amazon SQS' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Amazon SQS');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
||||
@@ -341,12 +385,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
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 openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('← Back to list'));
|
||||
@@ -420,12 +461,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdWebhook]);
|
||||
|
||||
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 openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save as Disabled' }));
|
||||
@@ -453,8 +491,9 @@ describe('SettingsFanoutSection', () => {
|
||||
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 openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('Webhook #3'));
|
||||
});
|
||||
|
||||
@@ -656,21 +695,21 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Broker: mqtt-us-v1.letsmesh.net:443')).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
|
||||
const group = await screen.findByRole('group', { name: 'Integration Community Feed' });
|
||||
expect(
|
||||
within(group).getByText(
|
||||
(_, element) => element?.textContent === 'Broker: mqtt-us-v1.letsmesh.net:443'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(within(group).getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Region: LAX')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('MeshRank preset pre-fills the broker settings and asks for the topic template', 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: 'MeshRank' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('MeshRank');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
@@ -707,12 +746,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||
|
||||
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: 'MeshRank' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('MeshRank');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Packet Topic Template'), {
|
||||
@@ -774,12 +810,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||
|
||||
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: 'LetsMesh (US)' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('LetsMesh (US)');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('LetsMesh (US)');
|
||||
@@ -842,12 +875,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||
|
||||
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: 'LetsMesh (EU)' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('LetsMesh (EU)');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } });
|
||||
@@ -880,12 +910,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('generic Community MQTT entry still opens the full editor', 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: 'Community MQTT/meshcoretomqtt' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Community MQTT/meshcoretomqtt');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
@@ -909,9 +936,12 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([privateConfig]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Broker: broker.local:1883')).toBeInTheDocument());
|
||||
const group = await screen.findByRole('group', { name: 'Integration Private Broker' });
|
||||
expect(
|
||||
screen.getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
|
||||
within(group).getByText((_, element) => element?.textContent === 'Broker: broker.local:1883')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(group).getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -929,7 +959,8 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('https://example.com/hook')).toBeInTheDocument());
|
||||
const group = await screen.findByRole('group', { name: 'Integration Webhook Feed' });
|
||||
expect(within(group).getByText('https://example.com/hook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('apprise list shows compact target summary', async () => {
|
||||
@@ -950,9 +981,10 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)).toBeInTheDocument()
|
||||
);
|
||||
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
|
||||
expect(
|
||||
within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sqs list shows queue url summary', async () => {
|
||||
@@ -972,11 +1004,10 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
|
||||
).toBeInTheDocument()
|
||||
);
|
||||
const group = await screen.findByRole('group', { name: 'Integration Queue Feed' });
|
||||
expect(
|
||||
within(group).getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('groups integrations by type and sorts entries alphabetically within each group', async () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import http from 'http';
|
||||
|
||||
function escapeRegex(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function createCaptureServer(urlFactory: (port: number) => string) {
|
||||
const requests: { body: string; headers: http.IncomingHttpHeaders }[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
@@ -38,6 +42,15 @@ export async function openFanoutSettings(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
|
||||
}
|
||||
|
||||
export async function startIntegrationDraft(page: Page, integrationName: string): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Create Integration' });
|
||||
await dialog
|
||||
.getByRole('button', { name: new RegExp(`^${escapeRegex(integrationName)}(?:\\s|$)`) })
|
||||
.click();
|
||||
await dialog.getByRole('button', { name: 'Create' }).click();
|
||||
}
|
||||
|
||||
export function fanoutHeader(page: Page, name: string): Locator {
|
||||
const nameButton = page.getByRole('button', { name, exact: true });
|
||||
return page
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
deleteFanoutConfig,
|
||||
getFanoutConfigs,
|
||||
} from '../helpers/api';
|
||||
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
|
||||
import {
|
||||
createCaptureServer,
|
||||
fanoutHeader,
|
||||
openFanoutSettings,
|
||||
startIntegrationDraft,
|
||||
} from '../helpers/fanout';
|
||||
|
||||
test.describe('Apprise integration settings', () => {
|
||||
let createdAppriseId: string | null = null;
|
||||
@@ -35,9 +40,7 @@ test.describe('Apprise integration settings', () => {
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
// Open add menu and pick Apprise
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Apprise' }).click();
|
||||
await startIntegrationDraft(page, 'Apprise');
|
||||
|
||||
// Should navigate to the detail/edit view with a numbered default name
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Apprise #\d+/);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
ensureFlightlessChannel,
|
||||
createFanoutConfig,
|
||||
deleteFanoutConfig,
|
||||
getFanoutConfigs,
|
||||
} from '../helpers/api';
|
||||
import { openFanoutSettings, startIntegrationDraft } from '../helpers/fanout';
|
||||
|
||||
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
|
||||
@@ -28,32 +29,35 @@ test.describe('Bot functionality', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('create a bot via API, verify it in UI, trigger it, and verify response', async ({
|
||||
test('create a bot via UI, trigger it, and verify response', async ({
|
||||
page,
|
||||
}) => {
|
||||
// --- Step 1: Create and enable bot via fanout API ---
|
||||
const bot = await createFanoutConfig({
|
||||
type: 'bot',
|
||||
name: 'E2E Test Bot',
|
||||
config: { code: BOT_CODE },
|
||||
enabled: true,
|
||||
});
|
||||
createdBotId = bot.id;
|
||||
|
||||
// --- Step 2: Verify bot appears in settings UI ---
|
||||
await page.goto('/');
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
await page.getByText('Settings').click();
|
||||
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
|
||||
await startIntegrationDraft(page, 'Python Bot');
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Python Bot #\d+/);
|
||||
|
||||
await page.locator('#fanout-edit-name').fill('E2E Test Bot');
|
||||
|
||||
const codeEditor = page.getByLabel('Bot code editor');
|
||||
await codeEditor.click();
|
||||
await codeEditor.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
|
||||
await codeEditor.fill(BOT_CODE);
|
||||
|
||||
await page.getByRole('button', { name: /Save as Enabled/i }).click();
|
||||
await expect(page.getByText('Integration saved and enabled')).toBeVisible();
|
||||
|
||||
// The bot name should be visible in the integration list
|
||||
await expect(page.getByText('E2E Test Bot')).toBeVisible();
|
||||
|
||||
// Exit settings page mode
|
||||
const configs = await getFanoutConfigs();
|
||||
const createdBot = configs.find((config) => config.name === 'E2E Test Bot');
|
||||
if (createdBot) {
|
||||
createdBotId = createdBot.id;
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: /Back to Chat/i }).click();
|
||||
|
||||
// --- Step 3: Trigger the bot ---
|
||||
await page.getByText('#flightless', { exact: true }).first().click();
|
||||
|
||||
const triggerMessage = `!e2etest ${Date.now()}`;
|
||||
@@ -61,8 +65,6 @@ test.describe('Bot functionality', () => {
|
||||
await input.fill(triggerMessage);
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
|
||||
// --- Step 4: Verify bot response appears ---
|
||||
// Bot has ~2s delay before responding, plus radio send time
|
||||
await expect(page.getByText('[BOT] e2e-ok')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
deleteFanoutConfig,
|
||||
getFanoutConfigs,
|
||||
} from '../helpers/api';
|
||||
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
|
||||
import {
|
||||
createCaptureServer,
|
||||
fanoutHeader,
|
||||
openFanoutSettings,
|
||||
startIntegrationDraft,
|
||||
} from '../helpers/fanout';
|
||||
|
||||
test.describe('Webhook integration settings', () => {
|
||||
let createdWebhookId: string | null = null;
|
||||
@@ -35,9 +40,7 @@ test.describe('Webhook integration settings', () => {
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
// Open add menu and pick Webhook
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Webhook' }).click();
|
||||
await startIntegrationDraft(page, 'Webhook');
|
||||
|
||||
// Should navigate to the detail/edit view with a numbered default name
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
|
||||
@@ -77,8 +80,7 @@ test.describe('Webhook integration settings', () => {
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Webhook' }).click();
|
||||
await startIntegrationDraft(page, 'Webhook');
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
|
||||
|
||||
await page.locator('#fanout-edit-name').fill('Unsaved Webhook Draft');
|
||||
|
||||
Reference in New Issue
Block a user