From da22eb5c4806a5fb178e1af48e5034e4ef679532 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 6 Mar 2026 21:37:11 -0800 Subject: [PATCH] Fanout integration UX overhaul --- frontend/src/components/SettingsModal.tsx | 4 +- .../settings/SettingsFanoutSection.tsx | 463 ++++++++++++------ frontend/src/test/appFavorites.test.tsx | 3 + frontend/src/test/fanoutSection.test.tsx | 248 ++++++++-- frontend/src/test/settingsModal.test.tsx | 10 + tests/e2e/helpers/fanout.ts | 48 ++ tests/e2e/specs/apprise.spec.ts | 85 ++-- tests/e2e/specs/webhook.spec.ts | 95 ++-- 8 files changed, 687 insertions(+), 269 deletions(-) create mode 100644 tests/e2e/helpers/fanout.ts diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 3cbb851..f056821 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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' diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 2b26a8d..a764e3f 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -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([]); const [editingId, setEditingId] = useState(null); + const [draftType, setDraftType] = useState(null); const [editConfig, setEditConfig] = useState>({}); const [editScope, setEditScope] = useState>({}); const [editName, setEditName] = useState(''); + const [inlineEditingId, setInlineEditingId] = useState(null); + const [inlineEditName, setInlineEditName] = useState(''); + const [addMenuOpen, setAddMenuOpen] = useState(false); const [busy, setBusy] = useState(false); + const addMenuRef = useRef(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 = { - 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 = { + 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 ( -
+
@@ -1171,12 +1258,12 @@ export function SettingsFanoutSection({
- Type: {TYPE_LABELS[editingConfig.type] || editingConfig.type} + Type: {TYPE_LABELS[detailType] || detailType}
- {editingConfig.type === 'mqtt_private' && ( + {detailType === 'mqtt_private' && ( )} - {editingConfig.type === 'mqtt_community' && ( + {detailType === 'mqtt_community' && ( )} - {editingConfig.type === 'bot' && ( - - )} + {detailType === 'bot' && } - {editingConfig.type === 'apprise' && ( + {detailType === 'apprise' && ( )} - {editingConfig.type === 'webhook' && ( + {detailType === 'webhook' && ( {busy ? 'Saving...' : 'Save as Disabled'} - + {!isDraft && editingConfig && ( + + )}
); @@ -1239,7 +1326,7 @@ export function SettingsFanoutSection({ // List view return ( -
+
Integrations are an experimental feature in open beta.
@@ -1251,134 +1338,194 @@ export function SettingsFanoutSection({
)} -
- Add a new entry: - {TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map((opt) => ( - + {addMenuOpen && ( +
- {opt.label} - - ))} + {TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map( + (opt) => ( + + ) + )} +
+ )}
- {configs.length > 0 && ( -
- {configs.map((cfg) => { - const statusEntry = health?.fanout_statuses?.[cfg.id]; - const status = cfg.enabled ? statusEntry?.status : undefined; - const communityConfig = cfg.config as Record; - return ( -
-
- + {configGroups.length > 0 && ( +
+ {configGroups.map((group) => ( +
+
{group.label}
+
+ {group.configs.map((cfg) => { + const statusEntry = health?.fanout_statuses?.[cfg.id]; + const status = cfg.enabled ? statusEntry?.status : undefined; + const communityConfig = cfg.config as Record; + return ( +
+
+ - {cfg.name} +
+ {inlineEditingId === cfg.id ? ( + 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" + /> + ) : ( + + )} +
- - {TYPE_LABELS[cfg.type] || cfg.type} - + + {cfg.type === 'mqtt_community' && ( +
+
+ Broker:{' '} + {formatBrokerSummary(communityConfig, { + host: DEFAULT_COMMUNITY_BROKER_HOST, + port: DEFAULT_COMMUNITY_BROKER_PORT, + })} +
+
+ Topic:{' '} + + {(communityConfig.topic_template as string) || + DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE} + +
+
+ )} - {cfg.type === 'mqtt_community' && ( -
-
- Broker:{' '} - {formatBrokerSummary(communityConfig, { - host: DEFAULT_COMMUNITY_BROKER_HOST, - port: DEFAULT_COMMUNITY_BROKER_PORT, - })} + {cfg.type === 'mqtt_private' && ( +
+
+ Broker:{' '} + {formatBrokerSummary(cfg.config as Record, { + host: '', + port: 1883, + })} +
+
+ Topics:{' '} + + {formatPrivateTopicSummary(cfg.config as Record)} + +
+
+ )} + + {cfg.type === 'webhook' && ( +
+
+ URL:{' '} + + {((cfg.config as Record).url as string) || 'Not set'} + +
+
+ )} + + {cfg.type === 'apprise' && ( +
+
+ Targets:{' '} + + {formatAppriseTargets( + (cfg.config as Record).urls as string | undefined + )} + +
+
+ )}
-
- Topic:{' '} - - {(communityConfig.topic_template as string) || - DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE} - -
-
- )} - - {cfg.type === 'mqtt_private' && ( -
-
- Broker:{' '} - {formatBrokerSummary(cfg.config as Record, { - host: '', - port: 1883, - })} -
-
- Topics:{' '} - - {formatPrivateTopicSummary(cfg.config as Record)} - -
-
- )} - - {cfg.type === 'webhook' && ( -
-
- URL:{' '} - - {((cfg.config as Record).url as string) || 'Not set'} - -
-
- )} - - {cfg.type === 'apprise' && ( -
-
- Targets:{' '} - - {formatAppriseTargets( - (cfg.config as Record).urls as string | undefined - )} - -
-
- )} + ); + })}
- ); - })} +
+ ))}
)}
diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 6bc2630..2d9c7e4 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -185,6 +185,9 @@ const baseSettings = { preferences_migrated: false, advert_interval: 0, last_advert_time: 0, + flood_scope: '', + blocked_keys: [], + blocked_names: [], }; const publicChannel = { diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx index e88ecd6..6c399e7 100644 --- a/frontend/src/test/fanoutSection.test.tsx +++ b/frontend/src/test/fanoutSection.test.tsx @@ -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(); + }); }); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index 96fa50f..df482e4 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -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]; diff --git a/tests/e2e/helpers/fanout.ts b/tests/e2e/helpers/fanout.ts new file mode 100644 index 0000000..d8e9946 --- /dev/null +++ b/tests/e2e/helpers/fanout.ts @@ -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 { + 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 { + 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(); +} diff --git a/tests/e2e/specs/apprise.spec.ts b/tests/e2e/specs/apprise.spec.ts index e966603..87fe328 100644 --- a/tests/e2e/specs/apprise.spec.ts +++ b/tests/e2e/specs/apprise.spec.ts @@ -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; + 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; }); }); diff --git a/tests/e2e/specs/webhook.spec.ts b/tests/e2e/specs/webhook.spec.ts index 0c96483..fa9fa0f 100644 --- a/tests/e2e/specs/webhook.spec.ts +++ b/tests/e2e/specs/webhook.spec.ts @@ -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; + 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;