Bind alert notification form labels

This commit is contained in:
rcourtman
2026-05-20 14:28:35 +01:00
parent b8d23ae616
commit d02b9ef7ff
4 changed files with 176 additions and 19 deletions
@@ -133,6 +133,26 @@ describe('EmailProviderSelect', () => {
);
});
it('associates visible labels with manual SMTP fields', () => {
render(() => (
<EmailProviderSelect config={makeConfig()} onChange={onChangeMock} onTest={onTestMock} />
));
expect(screen.getByRole('textbox', { name: 'SMTP server' })).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: 'SMTP port' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'From address' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Reply-to address' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Username' })).toBeInTheDocument();
expect(screen.getByLabelText('Password / API key')).toHaveAttribute('type', 'password');
expect(screen.getByRole('textbox', { name: 'Recipients (one per line)' })).toBeInTheDocument();
fireEvent.click(screen.getByText('Show advanced options'));
expect(screen.getByRole('spinbutton', { name: 'Rate limit' })).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: 'Max retries' })).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: 'Retry delay (seconds)' })).toBeInTheDocument();
});
it('loads providers from the API and populates the select', async () => {
render(() => (
<EmailProviderSelect config={makeConfig()} onChange={onChangeMock} onTest={onTestMock} />
@@ -1,4 +1,4 @@
import { Show, For } from 'solid-js';
import { Show, For, createUniqueId } from 'solid-js';
import type { UIEmailConfig } from '@/features/alerts/types';
import {
ALERT_EMAIL_FROM_ADDRESS_LABEL,
@@ -46,6 +46,19 @@ interface EmailProviderSelectProps extends EmailProviderSelectStateProps {
export function EmailProviderSelect(props: EmailProviderSelectProps) {
const state = useEmailProviderSelectState(props);
const fieldIdPrefix = `alert-email-${createUniqueId()}`;
const fieldIds = {
smtpServer: `${fieldIdPrefix}-smtp-server`,
smtpPort: `${fieldIdPrefix}-smtp-port`,
fromAddress: `${fieldIdPrefix}-from-address`,
replyTo: `${fieldIdPrefix}-reply-to`,
username: `${fieldIdPrefix}-username`,
password: `${fieldIdPrefix}-password`,
recipients: `${fieldIdPrefix}-recipients`,
rateLimit: `${fieldIdPrefix}-rate-limit`,
maxRetries: `${fieldIdPrefix}-max-retries`,
retryDelay: `${fieldIdPrefix}-retry-delay`,
};
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';
@@ -103,8 +116,11 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
<div class="grid w-full gap-3 sm:grid-cols-2">
<div class={formField}>
<label class={labelClass()}>{ALERT_EMAIL_SMTP_SERVER_LABEL}</label>
<label for={fieldIds.smtpServer} class={labelClass()}>
{ALERT_EMAIL_SMTP_SERVER_LABEL}
</label>
<input
id={fieldIds.smtpServer}
type="text"
value={props.config.server}
onInput={(e) => props.onChange({ ...props.config, server: e.currentTarget.value })}
@@ -114,8 +130,11 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
</div>
<div class={formField}>
<label class={labelClass()}>{ALERT_EMAIL_SMTP_PORT_LABEL}</label>
<label for={fieldIds.smtpPort} class={labelClass()}>
{ALERT_EMAIL_SMTP_PORT_LABEL}
</label>
<input
id={fieldIds.smtpPort}
type="number"
min="1"
max="65535"
@@ -139,8 +158,11 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
</div>
<div class={formField}>
<label class={labelClass()}>{ALERT_EMAIL_FROM_ADDRESS_LABEL}</label>
<label for={fieldIds.fromAddress} class={labelClass()}>
{ALERT_EMAIL_FROM_ADDRESS_LABEL}
</label>
<input
id={fieldIds.fromAddress}
type="email"
value={props.config.from}
onInput={(e) => props.onChange({ ...props.config, from: e.currentTarget.value })}
@@ -150,8 +172,11 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
</div>
<div class={formField}>
<label class={labelClass()}>{ALERT_EMAIL_REPLY_TO_LABEL}</label>
<label for={fieldIds.replyTo} class={labelClass()}>
{ALERT_EMAIL_REPLY_TO_LABEL}
</label>
<input
id={fieldIds.replyTo}
type="email"
value={props.config.replyTo || ''}
onInput={(e) => props.onChange({ ...props.config, replyTo: e.currentTarget.value })}
@@ -161,8 +186,11 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
</div>
<div class={formField}>
<label class={labelClass()}>{ALERT_EMAIL_USERNAME_LABEL}</label>
<label for={fieldIds.username} class={labelClass()}>
{ALERT_EMAIL_USERNAME_LABEL}
</label>
<input
id={fieldIds.username}
type="text"
value={props.config.username}
onInput={(e) => props.onChange({ ...props.config, username: e.currentTarget.value })}
@@ -172,8 +200,11 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
</div>
<div class={formField}>
<label class={labelClass()}>{ALERT_EMAIL_PASSWORD_LABEL}</label>
<label for={fieldIds.password} class={labelClass()}>
{ALERT_EMAIL_PASSWORD_LABEL}
</label>
<input
id={fieldIds.password}
type="password"
value={props.config.password}
onInput={(e) => props.onChange({ ...props.config, password: e.currentTarget.value })}
@@ -184,8 +215,11 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
</div>
<div class={formField}>
<label class={labelClass()}>{ALERT_EMAIL_RECIPIENTS_LABEL}</label>
<label for={fieldIds.recipients} class={labelClass()}>
{ALERT_EMAIL_RECIPIENTS_LABEL}
</label>
<textarea
id={fieldIds.recipients}
value={props.config.to.join('\n')}
onInput={(e) => {
// Keep raw lines while editing — trimming/filtering on every
@@ -240,10 +274,14 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
<option value="tls">{ALERT_EMAIL_SECURITY_TLS_LABEL}</option>
</FormSelect>
<div class="flex w-full flex-wrap items-center gap-2 sm:flex-nowrap">
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label
for={fieldIds.rateLimit}
class={labelClass('text-xs uppercase tracking-[0.08em]')}
>
{ALERT_EMAIL_RATE_LIMIT_LABEL}
</label>
<input
id={fieldIds.rateLimit}
type="number"
min="1"
value={props.config.rateLimit || 60}
@@ -258,10 +296,14 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
<div class="grid w-full gap-3 sm:grid-cols-2">
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label
for={fieldIds.maxRetries}
class={labelClass('text-xs uppercase tracking-[0.08em]')}
>
{ALERT_EMAIL_MAX_RETRIES_LABEL}
</label>
<input
id={fieldIds.maxRetries}
type="number"
value={props.config.maxRetries || 3}
min={0}
@@ -273,10 +315,14 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
/>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label
for={fieldIds.retryDelay}
class={labelClass('text-xs uppercase tracking-[0.08em]')}
>
{ALERT_EMAIL_RETRY_DELAY_LABEL}
</label>
<input
id={fieldIds.retryDelay}
type="number"
value={props.config.retryDelay || 5}
min={1}
@@ -0,0 +1,59 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AlertAppriseDestinationsSection } from './AlertAppriseDestinationsSection';
import type { UIAppriseConfig } from './types';
function makeConfig(overrides: Partial<UIAppriseConfig> = {}): UIAppriseConfig {
return {
enabled: true,
mode: 'cli',
targetsText: '',
cliPath: 'apprise',
timeoutSeconds: 15,
serverUrl: '',
configKey: 'default',
apiKey: '',
apiKeyHeader: 'X-API-KEY',
skipTlsVerify: false,
...overrides,
};
}
function renderSection(config: UIAppriseConfig) {
return render(() => (
<AlertAppriseDestinationsSection
config={config}
updateApprise={vi.fn()}
setHasUnsavedChanges={vi.fn()}
onTest={vi.fn()}
testing={false}
/>
));
}
describe('AlertAppriseDestinationsSection', () => {
afterEach(() => {
cleanup();
});
it('associates visible labels with CLI mode fields', () => {
renderSection(makeConfig({ mode: 'cli' }));
expect(screen.getByRole('combobox', { name: 'Delivery mode' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Delivery targets' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'CLI path' })).toBeInTheDocument();
expect(screen.getByRole('spinbutton', { name: 'Timeout (seconds)' })).toBeInTheDocument();
});
it('associates visible labels with HTTP mode fields', () => {
renderSection(makeConfig({ mode: 'http' }));
expect(screen.getByRole('textbox', { name: 'Server URL' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Config key (optional)' })).toBeInTheDocument();
expect(screen.getByLabelText('API key')).toHaveAttribute('type', 'password');
expect(screen.getByRole('textbox', { name: 'API key header' })).toBeInTheDocument();
expect(
screen.getByRole('checkbox', { name: 'Allow self-signed certificates' }),
).toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import { Show } from 'solid-js';
import { Show, createUniqueId } from 'solid-js';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import { TlsVerificationWarningBanner } from '@/components/shared/TlsVerificationWarningBanner';
@@ -49,6 +49,18 @@ interface AlertAppriseDestinationsSectionProps {
}
export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsSectionProps) {
const fieldIdPrefix = `alert-apprise-${createUniqueId()}`;
const fieldIds = {
targets: `${fieldIdPrefix}-targets`,
cliPath: `${fieldIdPrefix}-cli-path`,
serverUrl: `${fieldIdPrefix}-server-url`,
configKey: `${fieldIdPrefix}-config-key`,
apiKey: `${fieldIdPrefix}-api-key`,
apiKeyHeader: `${fieldIdPrefix}-api-key-header`,
skipTlsVerify: `${fieldIdPrefix}-skip-tls-verify`,
timeout: `${fieldIdPrefix}-timeout`,
};
return (
<SettingsPanel
title={ALERT_DESTINATIONS_APPRISE_PANEL_TITLE}
@@ -97,10 +109,11 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
</FormSelect>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label for={fieldIds.targets} class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_TARGETS_LABEL}
</label>
<textarea
id={fieldIds.targets}
rows={4}
class={`${formControl} min-h-[120px] font-mono`}
value={props.config.targetsText}
@@ -115,10 +128,11 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
<Show when={props.config.mode === 'cli'}>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label for={fieldIds.cliPath} class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_CLI_PATH_LABEL}
</label>
<input
id={fieldIds.cliPath}
type="text"
value={props.config.cliPath}
class={formControl}
@@ -135,10 +149,14 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
<Show when={props.config.mode === 'http'}>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class={`${formField} sm:col-span-2`}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label
for={fieldIds.serverUrl}
class={labelClass('text-xs uppercase tracking-[0.08em]')}
>
{ALERT_DESTINATIONS_APPRISE_SERVER_URL_LABEL}
</label>
<input
id={fieldIds.serverUrl}
type="text"
value={props.config.serverUrl}
class={formControl}
@@ -151,10 +169,14 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_SERVER_URL_HELP}</p>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label
for={fieldIds.configKey}
class={labelClass('text-xs uppercase tracking-[0.08em]')}
>
{ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_LABEL}
</label>
<input
id={fieldIds.configKey}
type="text"
value={props.config.configKey}
class={formControl}
@@ -167,10 +189,14 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_HELP}</p>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label
for={fieldIds.apiKey}
class={labelClass('text-xs uppercase tracking-[0.08em]')}
>
{ALERT_DESTINATIONS_APPRISE_API_KEY_LABEL}
</label>
<input
id={fieldIds.apiKey}
type="password"
value={props.config.apiKey}
class={formControl}
@@ -183,10 +209,14 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
<p class={formHelpText}>{ALERT_DESTINATIONS_APPRISE_API_KEY_HELP}</p>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label
for={fieldIds.apiKeyHeader}
class={labelClass('text-xs uppercase tracking-[0.08em]')}
>
{ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_LABEL}
</label>
<input
id={fieldIds.apiKeyHeader}
type="text"
value={props.config.apiKeyHeader}
class={formControl}
@@ -211,6 +241,7 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
</Show>
<label class="inline-flex items-center gap-2">
<input
id={fieldIds.skipTlsVerify}
type="checkbox"
class="h-4 w-4 rounded border border-border"
checked={props.config.skipTlsVerify}
@@ -229,10 +260,11 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
</Show>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
<label for={fieldIds.timeout} class={labelClass('text-xs uppercase tracking-[0.08em]')}>
{ALERT_DESTINATIONS_APPRISE_TIMEOUT_LABEL}
</label>
<input
id={fieldIds.timeout}
type="number"
min="5"
max="120"