mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 08:33:03 +00:00
Bind alert notification form labels
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user