From 97d47a326fba5c430052ac57ff43f45615b426ba Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 22 Mar 2026 17:00:25 +0000 Subject: [PATCH] Extract alert email provider select runtime owner --- .../v6/internal/subsystems/alerts.md | 6 + .../subsystems/frontend-primitives.md | 6 + .../Alerts/EmailProviderSelect.test.tsx | 43 ++++--- .../components/Alerts/EmailProviderSelect.tsx | 111 ++++-------------- .../useEmailProviderSelectState.test.tsx | 106 +++++++++++++++++ .../Alerts/useEmailProviderSelectState.ts | 79 +++++++++++++ .../frontendResourceTypeBoundaries.test.ts | 8 ++ 7 files changed, 252 insertions(+), 107 deletions(-) create mode 100644 frontend-modern/src/components/Alerts/__tests__/useEmailProviderSelectState.test.tsx create mode 100644 frontend-modern/src/components/Alerts/useEmailProviderSelectState.ts diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 0525a9e073..0afb0e3b3b 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -258,6 +258,12 @@ transport hook, the config model, the override-projection hook, or the destinations runtime hook based on which subsystem actually owns the behavior instead of letting the broader configuration hook absorb all three concerns again. +The email destination provider picker now follows that same split: +`frontend-modern/src/components/Alerts/useEmailProviderSelectState.ts` owns +provider-catalog loading and provider-default application, while +`frontend-modern/src/components/Alerts/EmailProviderSelect.tsx` stays the +render shell and consumes the canonical `UIEmailConfig` feature type instead of +keeping a second local email-config interface. Alert filter metadata and grouped header consumers must also preserve the canonical `agent` and `node` header boundary when reusing shared filter diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5185339501..0df77960df 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -457,6 +457,12 @@ webhook runtime and destination test actions while render shell. Future cleanup should extend the transport hook, config model, override hook, or destinations runtime hook based on the true owner, not move config control flow back into the top-level page shell. +The alert email provider picker now also follows the shell/runtime split: +`frontend-modern/src/components/Alerts/useEmailProviderSelectState.ts` owns +provider-catalog loading and provider-default application, while +`frontend-modern/src/components/Alerts/EmailProviderSelect.tsx` stays the +render shell and should not re-accumulate `NotificationsAPI.getEmailProviders` +or a second local email-config contract inline. The same rule now also covers cross-tab incident timelines: the shared runtime owner is `frontend-modern/src/features/alerts/useAlertIncidentTimelineState.ts`, while `frontend-modern/src/features/alerts/OverviewTab.tsx` and diff --git a/frontend-modern/src/components/Alerts/EmailProviderSelect.test.tsx b/frontend-modern/src/components/Alerts/EmailProviderSelect.test.tsx index 673120a711..86da9543c6 100644 --- a/frontend-modern/src/components/Alerts/EmailProviderSelect.test.tsx +++ b/frontend-modern/src/components/Alerts/EmailProviderSelect.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createSignal } from 'solid-js'; import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import type { UIEmailConfig } from '@/features/alerts/types'; // --- Mock setup (must be before component import) --- const getEmailProvidersMock = vi.fn(); @@ -22,24 +24,7 @@ import { EmailProviderSelect } from './EmailProviderSelect'; import { logger } from '@/utils/logger'; // --- Helpers --- -interface EmailConfig { - enabled: boolean; - provider: string; - server: string; - port: number; - from: string; - username: string; - password: string; - to: string[]; - tls: boolean; - startTLS: boolean; - replyTo: string; - maxRetries: number; - retryDelay: number; - rateLimit: number; -} - -function makeConfig(overrides: Partial = {}): EmailConfig { +function makeConfig(overrides: Partial = {}): UIEmailConfig { return { enabled: true, provider: '', @@ -615,6 +600,28 @@ describe('EmailProviderSelect', () => { expect(afterHide.length).toBeLessThan(instructionTexts.length); }); + it('resets the mobile instructions toggle after switching back to manual configuration', async () => { + const Wrapper = () => { + const [config, setConfig] = createSignal(makeConfig({ provider: 'Gmail' })); + return ; + }; + + render(() => ); + + await waitFor(() => { + expect(screen.getByText('Gmail (smtp.gmail.com:587)')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Show setup instructions')); + expect(screen.getByText('Hide setup instructions')).toBeInTheDocument(); + + const providerSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement; + fireEvent.change(providerSelect, { target: { value: '' } }); + + expect(screen.queryByText('Hide setup instructions')).not.toBeInTheDocument(); + expect(screen.queryByText('Show setup instructions')).not.toBeInTheDocument(); + }); + // --- Test Email Button --- it('renders "Send test email" button', async () => { diff --git a/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx b/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx index b8a96a2520..a991c127b3 100644 --- a/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx +++ b/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx @@ -1,6 +1,5 @@ -import { createSignal, createEffect, Show, For } from 'solid-js'; -import { NotificationsAPI } from '@/api/notifications'; -import { logger } from '@/utils/logger'; +import { Show, For } from 'solid-js'; +import type { UIEmailConfig } from '@/features/alerts/types'; import { ALERT_EMAIL_FROM_ADDRESS_LABEL, ALERT_EMAIL_FROM_ADDRESS_PLACEHOLDER, @@ -33,85 +32,19 @@ import { getAlertEmailUsernamePlaceholder, } from '@/utils/alertEmailPresentation'; import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form'; +import { + useEmailProviderSelectState, + type EmailProviderSelectStateProps, +} from './useEmailProviderSelectState'; -interface EmailProvider { - name: string; - smtpHost: string; - smtpPort: number; - tls: boolean; - startTLS: boolean; - authRequired: boolean; - instructions: string; -} - -interface EmailConfig { - enabled: boolean; - provider: string; - server: string; - port: number; - from: string; - username: string; - password: string; - to: string[]; - tls: boolean; - startTLS: boolean; - replyTo: string; - maxRetries: number; - retryDelay: number; - rateLimit: number; -} - -interface EmailProviderSelectProps { - config: EmailConfig; - onChange: (config: EmailConfig) => void; +interface EmailProviderSelectProps extends EmailProviderSelectStateProps { + config: UIEmailConfig; onTest: () => void; testing?: boolean; } export function EmailProviderSelect(props: EmailProviderSelectProps) { - const [providers, setProviders] = createSignal([]); - const [showAdvanced, setShowAdvanced] = createSignal(false); - const [showInstructions, setShowInstructions] = createSignal(false); - - // Load email providers once - createEffect(async () => { - try { - const data = await NotificationsAPI.getEmailProviders(); - setProviders(data); - } catch (err) { - logger.error('Failed to load email providers', err); - } - }); - - const applyProvider = (provider: EmailProvider | undefined) => { - if (!provider) { - props.onChange({ ...props.config, provider: '' }); - setShowInstructions(false); - return; - } - - props.onChange({ - ...props.config, - provider: provider.name, - server: provider.smtpHost, - port: provider.smtpPort, - tls: provider.tls, - startTLS: provider.startTLS, - username: provider.name === 'SendGrid' ? 'apikey' : props.config.username, - }); - setShowInstructions(true); - }; - - const handleProviderChange = (value: string) => { - if (!value) { - applyProvider(undefined); - return; - } - const provider = providers().find((p) => p.name === value); - applyProvider(provider); - }; - - const currentProvider = () => providers().find((p) => p.name === props.config.provider); + const state = useEmailProviderSelectState(props); const instructionBoxClass = 'mt-2 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs leading-relaxed text-blue-900 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200'; @@ -122,11 +55,11 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {