Allow multiline email recipient entry

Refs #1462
This commit is contained in:
rcourtman 2026-05-24 22:01:58 +01:00
parent f32a3e5235
commit 98945e3228
2 changed files with 91 additions and 8 deletions

View file

@ -1,4 +1,4 @@
import { createSignal, createEffect, Show, For } from 'solid-js';
import { createSignal, createEffect, Show, For, untrack } from 'solid-js';
import { NotificationsAPI } from '@/api/notifications';
import { logger } from '@/utils/logger';
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
@ -41,6 +41,8 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
const [providers, setProviders] = createSignal<EmailProvider[]>([]);
const [showAdvanced, setShowAdvanced] = createSignal(false);
const [showInstructions, setShowInstructions] = createSignal(false);
const [recipientsText, setRecipientsText] = createSignal(props.config.to.join('\n'));
let recipientsTextareaRef: HTMLTextAreaElement | undefined;
// Load email providers once
createEffect(async () => {
@ -82,6 +84,19 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
const currentProvider = () => providers().find((p) => p.name === props.config.provider);
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/20 dark:text-blue-200";
const parseRecipients = (value: string) =>
value
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
createEffect(() => {
const next = props.config.to.join('\n');
if (document.activeElement === recipientsTextareaRef) return;
if (untrack(recipientsText) !== next) {
setRecipientsText(next);
}
});
return (
<div class="space-y-4 text-sm overflow-hidden">
@ -220,15 +235,21 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
</div>
<div class={formField}>
<label class={labelClass()}>Recipients (one per line)</label>
<label for="email-recipients" class={labelClass()}>Recipients (one per line)</label>
<textarea
value={props.config.to.join('\n')}
id="email-recipients"
ref={recipientsTextareaRef}
value={recipientsText()}
onInput={(e) => {
const recipients = e.currentTarget.value
.split('\n')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
props.onChange({ ...props.config, to: recipients });
const value = e.currentTarget.value;
setRecipientsText(value);
props.onChange({ ...props.config, to: parseRecipients(value) });
}}
onBlur={() => {
const next = props.config.to.join('\n');
if (recipientsText() !== next) {
setRecipientsText(next);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {

View file

@ -0,0 +1,62 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EmailProviderSelect } from '../EmailProviderSelect';
vi.mock('@/api/notifications', () => ({
NotificationsAPI: {
getEmailProviders: vi.fn().mockResolvedValue([]),
},
}));
const baseConfig = {
enabled: true,
provider: '',
server: 'smtp.example.com',
port: 587,
from: 'pulse@example.com',
username: '',
password: '',
to: [] as string[],
tls: false,
startTLS: true,
replyTo: '',
maxRetries: 3,
retryDelay: 60,
rateLimit: 60,
};
afterEach(() => {
cleanup();
});
describe('EmailProviderSelect', () => {
it('keeps a trailing recipient newline editable while saving cleaned recipients', async () => {
const [config, setConfig] = createSignal(baseConfig);
const onTest = vi.fn();
render(() => (
<EmailProviderSelect
config={config()}
onChange={(next) => setConfig(next)}
onTest={onTest}
/>
));
const recipients = screen.getByLabelText('Recipients (one per line)') as HTMLTextAreaElement;
recipients.focus();
await fireEvent.input(recipients, { target: { value: 'admin@example.com\n' } });
expect(recipients.value).toBe('admin@example.com\n');
expect(config().to).toEqual(['admin@example.com']);
await fireEvent.input(recipients, {
target: { value: 'admin@example.com\nops@example.com' },
});
expect(recipients.value).toBe('admin@example.com\nops@example.com');
expect(config().to).toEqual(['admin@example.com', 'ops@example.com']);
});
});