Extract alert email provider select runtime owner

This commit is contained in:
rcourtman
2026-03-22 17:00:25 +00:00
parent 111d725c53
commit 97d47a326f
7 changed files with 252 additions and 107 deletions
@@ -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
@@ -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
@@ -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> = {}): EmailConfig {
function makeConfig(overrides: Partial<UIEmailConfig> = {}): 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 <EmailProviderSelect config={config()} onChange={setConfig} onTest={onTestMock} />;
};
render(() => <Wrapper />);
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 () => {
@@ -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<EmailProvider[]>([]);
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) {
<div class="flex w-full flex-wrap items-center gap-2 sm:flex-nowrap">
<select
value={props.config.provider}
onChange={(e) => handleProviderChange(e.currentTarget.value)}
onChange={(e) => state.handleProviderChange(e.currentTarget.value)}
class={`${controlClass('px-2 py-1.5')} sm:w-auto sm:min-w-[180px]`}
>
<option value="">{ALERT_EMAIL_MANUAL_CONFIGURATION_LABEL}</option>
<For each={providers()}>
<For each={state.providers()}>
{(provider) => (
<option value={provider.name}>{getAlertEmailProviderOptionLabel(provider)}</option>
)}
@@ -136,8 +69,8 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
<button
type="button"
onClick={() => {
const provider = currentProvider();
if (provider) applyProvider(provider);
const provider = state.currentProvider();
if (provider) state.applyProviderDefaults(provider);
}}
class="text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
>
@@ -147,21 +80,21 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
</div>
</div>
<Show when={currentProvider()}>
<Show when={state.currentProvider()}>
<div class="sm:hidden w-full">
<button
type="button"
onClick={() => setShowInstructions(!showInstructions())}
onClick={state.toggleShowInstructions}
class="text-xs font-medium text-blue-600 hover:underline dark:text-blue-300"
>
{getAlertEmailSetupInstructionsToggleLabel(showInstructions())}
{getAlertEmailSetupInstructionsToggleLabel(state.showInstructions())}
</button>
<Show when={showInstructions()}>
<div class={instructionBoxClass}>{currentProvider()!.instructions}</div>
<Show when={state.showInstructions()}>
<div class={instructionBoxClass}>{state.currentProvider()!.instructions}</div>
</Show>
</div>
<div class="hidden w-full sm:block">
<div class={instructionBoxClass}>{currentProvider()!.instructions}</div>
<div class={instructionBoxClass}>{state.currentProvider()!.instructions}</div>
</div>
</Show>
@@ -270,13 +203,13 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
<div class="border-t border-border pt-3">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced())}
onClick={state.toggleShowAdvanced}
class="text-xs font-semibold uppercase tracking-wide transition-colors hover:text-muted"
>
{getAlertEmailAdvancedToggleLabel(showAdvanced())}
{getAlertEmailAdvancedToggleLabel(state.showAdvanced())}
</button>
<Show when={showAdvanced()}>
<Show when={state.showAdvanced()}>
<div class="mt-3 space-y-3 text-xs text-base-content">
<div class="grid gap-3 sm:grid-cols-3">
<div class="flex items-center gap-2">
@@ -0,0 +1,106 @@
import { renderHook, waitFor } from '@solidjs/testing-library';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NotificationsAPI } from '@/api/notifications';
import { useEmailProviderSelectState } from '../useEmailProviderSelectState';
vi.mock('@/api/notifications', () => ({
NotificationsAPI: {
getEmailProviders: vi.fn(),
},
}));
vi.mock('@/utils/logger', () => ({
logger: {
error: vi.fn(),
},
}));
const gmailProvider = {
name: 'Gmail',
smtpHost: 'smtp.gmail.com',
smtpPort: 587,
tls: false,
startTLS: true,
authRequired: true,
instructions: 'Use an App Password from your Google Account settings.',
};
const sendgridProvider = {
name: 'SendGrid',
smtpHost: 'smtp.sendgrid.net',
smtpPort: 587,
tls: false,
startTLS: true,
authRequired: true,
instructions: 'Create an API key in the SendGrid dashboard.',
};
const buildConfig = (overrides: Record<string, unknown> = {}) => ({
enabled: true,
provider: '',
server: '',
port: 587,
username: 'ops@example.com',
password: '',
from: 'pulse@example.com',
to: ['alerts@example.com'],
tls: false,
startTLS: true,
replyTo: '',
maxRetries: 3,
retryDelay: 5,
rateLimit: 60,
...overrides,
});
describe('useEmailProviderSelectState', () => {
beforeEach(() => {
vi.mocked(NotificationsAPI.getEmailProviders).mockReset();
});
it('owns provider catalog loading and provider-default application separately from the render shell', async () => {
vi.mocked(NotificationsAPI.getEmailProviders).mockResolvedValue([
gmailProvider,
sendgridProvider,
] as never);
const onChange = vi.fn();
const { result } = renderHook(() =>
useEmailProviderSelectState({
config: buildConfig({ provider: 'Gmail' }),
onChange,
}),
);
await waitFor(() => expect(NotificationsAPI.getEmailProviders).toHaveBeenCalledTimes(1));
expect(result.providers()).toEqual([
expect.objectContaining({ name: 'Gmail' }),
expect.objectContaining({ name: 'SendGrid' }),
]);
expect(result.currentProvider()).toEqual(expect.objectContaining({ name: 'Gmail' }));
result.handleProviderChange('SendGrid');
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'SendGrid',
server: 'smtp.sendgrid.net',
port: 587,
username: 'apikey',
}),
);
onChange.mockClear();
result.handleProviderChange('');
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ provider: '' }));
expect(result.showAdvanced()).toBe(false);
result.toggleShowAdvanced();
expect(result.showAdvanced()).toBe(true);
expect(result.showInstructions()).toBe(false);
result.toggleShowInstructions();
expect(result.showInstructions()).toBe(true);
});
});
@@ -0,0 +1,79 @@
import { createEffect, createMemo, createSignal } from 'solid-js';
import { NotificationsAPI, type EmailProvider } from '@/api/notifications';
import type { UIEmailConfig } from '@/features/alerts/types';
import { logger } from '@/utils/logger';
export interface EmailProviderSelectStateProps {
config: UIEmailConfig;
onChange: (config: UIEmailConfig) => void;
}
export function useEmailProviderSelectState(props: EmailProviderSelectStateProps) {
const [providers, setProviders] = createSignal<EmailProvider[]>([]);
const [showAdvanced, setShowAdvanced] = createSignal(false);
const [showInstructions, setShowInstructions] = createSignal(false);
const loadEmailProviders = async () => {
try {
const data = await NotificationsAPI.getEmailProviders();
setProviders(data);
} catch (err) {
logger.error('Failed to load email providers', err);
}
};
createEffect(() => {
void loadEmailProviders();
});
const currentProvider = createMemo(() =>
providers().find((provider) => provider.name === props.config.provider),
);
const applyProviderDefaults = (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) {
applyProviderDefaults(undefined);
return;
}
applyProviderDefaults(providers().find((provider) => provider.name === value));
};
const toggleShowAdvanced = () => {
setShowAdvanced((value) => !value);
};
const toggleShowInstructions = () => {
setShowInstructions((value) => !value);
};
return {
providers,
currentProvider,
showAdvanced,
showInstructions,
handleProviderChange,
applyProviderDefaults,
toggleShowAdvanced,
toggleShowInstructions,
};
}
@@ -352,6 +352,7 @@ import alertResourceTableRowSource from '@/components/Alerts/AlertResourceTableR
import alertResourceTableSource from '@/components/Alerts/ResourceTable.tsx?raw';
import alertResourceTableStateSource from '@/components/Alerts/useAlertResourceTableState.ts?raw';
import emailProviderSelectSource from '@/components/Alerts/EmailProviderSelect.tsx?raw';
import emailProviderSelectStateSource from '@/components/Alerts/useEmailProviderSelectState.ts?raw';
import webhookConfigSource from '@/components/Alerts/WebhookConfig.tsx?raw';
import webhookConfigFormSource from '@/components/Alerts/WebhookConfigForm.tsx?raw';
import webhookConfigListSource from '@/components/Alerts/WebhookConfigList.tsx?raw';
@@ -3969,11 +3970,15 @@ describe('frontend resource type boundaries', () => {
});
it('keeps alert email provider vocabulary and placeholders in a shared presentation utility', () => {
expect(emailProviderSelectSource).toContain('useEmailProviderSelectState');
expect(emailProviderSelectSource).toContain('getAlertEmailProviderOptionLabel');
expect(emailProviderSelectSource).toContain('getAlertEmailSetupInstructionsToggleLabel');
expect(emailProviderSelectSource).toContain('getAlertEmailRecipientsPlaceholder');
expect(emailProviderSelectSource).toContain('getAlertEmailAdvancedToggleLabel');
expect(emailProviderSelectSource).toContain('getAlertEmailTestButtonLabel');
expect(emailProviderSelectSource).not.toContain('NotificationsAPI.getEmailProviders');
expect(emailProviderSelectSource).not.toContain('interface EmailProvider {');
expect(emailProviderSelectSource).not.toContain('interface EmailConfig {');
expect(emailProviderSelectSource).not.toContain('Manual configuration');
expect(emailProviderSelectSource).not.toContain('smtp.example.com');
expect(emailProviderSelectSource).not.toContain('noreply@example.com');
@@ -3987,6 +3992,9 @@ describe('frontend resource type boundaries', () => {
'export function getAlertEmailRecipientsPlaceholder',
);
expect(alertEmailPresentationSource).toContain('export function getAlertEmailTestButtonLabel');
expect(emailProviderSelectStateSource).toContain('export function useEmailProviderSelectState');
expect(emailProviderSelectStateSource).toContain('NotificationsAPI.getEmailProviders');
expect(emailProviderSelectStateSource).toContain("provider.name === 'SendGrid'");
});
it('keeps alert bulk-edit dialog labels in a shared presentation utility', () => {