mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-12 15:28:12 +00:00
Extract alert email provider select runtime owner
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user