mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-12 07:18:49 +00:00
Split alert webhook runtime owners
This commit is contained in:
@@ -76,6 +76,15 @@ backend webhook template registry, rather than keeping a second frontend-only
|
||||
list of services, labels, descriptions, and mention-copy metadata.
|
||||
The WebhookConfig editor now imports the shared webhook template API type
|
||||
directly so it does not retain a local duplicate shape for chooser metadata.
|
||||
That webhook editor now also keeps runtime ownership in
|
||||
`frontend-modern/src/components/Alerts/useWebhookConfigState.ts`, while
|
||||
`frontend-modern/src/components/Alerts/WebhookConfigList.tsx` owns the
|
||||
existing-webhook list surface and
|
||||
`frontend-modern/src/components/Alerts/WebhookConfigForm.tsx` owns the
|
||||
add/edit form surface. Future webhook template loading, form normalization,
|
||||
custom-field preset handling, or webhook editor state transitions should land
|
||||
in those owners instead of being rebuilt inline in
|
||||
`frontend-modern/src/components/Alerts/WebhookConfig.tsx`.
|
||||
|
||||
Alert spec validation still accepts the explicit migration-bridge resource
|
||||
types (`node`, `agent-disk`, `docker-host`, `backup-subject`,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import webhookConfigSource from '@/components/Alerts/WebhookConfig.tsx?raw';
|
||||
import webhookConfigFormSource from '@/components/Alerts/WebhookConfigForm.tsx?raw';
|
||||
import webhookConfigListSource from '@/components/Alerts/WebhookConfigList.tsx?raw';
|
||||
import webhookConfigStateSource from '@/components/Alerts/useWebhookConfigState.ts?raw';
|
||||
|
||||
// --- Mock setup (must be before component import) ---
|
||||
const getWebhookTemplatesMock = vi.fn();
|
||||
@@ -119,6 +123,22 @@ describe('WebhookConfig', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('keeps webhook runtime state and section owners out of the shell', () => {
|
||||
expect(webhookConfigSource).toContain('useWebhookConfigState');
|
||||
expect(webhookConfigSource).toContain('WebhookConfigList');
|
||||
expect(webhookConfigSource).toContain('WebhookConfigForm');
|
||||
expect(webhookConfigSource).not.toContain('NotificationsAPI.getWebhookTemplates');
|
||||
expect(webhookConfigSource).not.toContain('const saveWebhook = () => {');
|
||||
expect(webhookConfigSource).not.toContain('const editWebhook = (webhook: Webhook) => {');
|
||||
expect(webhookConfigSource).not.toContain('const toggleAllWebhooks = (enabled: boolean) => {');
|
||||
expect(webhookConfigListSource).toContain('getAlertWebhookSummaryLabel');
|
||||
expect(webhookConfigListSource).toContain('getAlertWebhookToggleAllLabel');
|
||||
expect(webhookConfigFormSource).toContain('getAlertWebhookServices');
|
||||
expect(webhookConfigFormSource).toContain('getAlertWebhookSubmitLabel');
|
||||
expect(webhookConfigStateSource).toContain('NotificationsAPI.getWebhookTemplates');
|
||||
expect(webhookConfigStateSource).toContain('normalizeAlertWebhookCustomFields');
|
||||
});
|
||||
|
||||
// --- Rendering existing webhooks ---
|
||||
|
||||
it('renders the "Add Webhook" button when no form is open', () => {
|
||||
|
||||
@@ -1,57 +1,13 @@
|
||||
import { createSignal, createEffect, Show, For, Index } from 'solid-js';
|
||||
import { NotificationsAPI, Webhook, type WebhookTemplate } from '@/api/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
import {
|
||||
ALERT_WEBHOOK_ADD_LABEL,
|
||||
ALERT_WEBHOOK_CANCEL_LABEL,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELDS_HELP,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELDS_PUSHHOVER_HELP,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELDS_REFERENCE,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELD_ADD_LABEL,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELD_KEY_PLACEHOLDER,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELD_REMOVE_LABEL,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELD_VALUE_PLACEHOLDER,
|
||||
ALERT_WEBHOOK_DELETE_LABEL,
|
||||
ALERT_WEBHOOK_EDIT_LABEL,
|
||||
ALERT_WEBHOOK_ENABLE_LABEL,
|
||||
ALERT_WEBHOOK_HEADER_ADD_LABEL,
|
||||
ALERT_WEBHOOK_HEADER_HELP,
|
||||
ALERT_WEBHOOK_HEADER_KEY_PLACEHOLDER,
|
||||
ALERT_WEBHOOK_HEADER_REMOVE_LABEL,
|
||||
ALERT_WEBHOOK_HEADER_VALUE_PLACEHOLDER,
|
||||
ALERT_WEBHOOK_HEADERS_HELP_LABEL,
|
||||
ALERT_WEBHOOK_MENTION_HELP_LABEL,
|
||||
ALERT_WEBHOOK_PAYLOAD_HELP_LABEL,
|
||||
ALERT_WEBHOOK_PAYLOAD_TEMPLATE_PLACEHOLDER,
|
||||
ALERT_WEBHOOK_PAYLOAD_VARIABLES,
|
||||
ALERT_WEBHOOK_URL_HELP_PATH,
|
||||
ALERT_WEBHOOK_URL_HELP_QUERY,
|
||||
ALERT_WEBHOOK_URL_HELP_TEMPLATE_VARIABLE,
|
||||
getAlertWebhookMentionHelpFromTemplates,
|
||||
getAlertWebhookMentionPlaceholderFromTemplates,
|
||||
hasAlertWebhookMentionSupportFromTemplates,
|
||||
getAlertWebhookCustomFieldInputs,
|
||||
getAlertWebhookNamePlaceholder,
|
||||
getAlertWebhookServiceLabelFromTemplates,
|
||||
getAlertWebhookServices,
|
||||
getAlertWebhookSetupInstructionsTitle,
|
||||
getAlertWebhookSubmitLabel,
|
||||
getAlertWebhookSummaryLabel,
|
||||
getAlertWebhookTestLabel,
|
||||
getAlertWebhookToggleAllLabel,
|
||||
getAlertWebhookToggleLabel,
|
||||
getAlertWebhookUrlPlaceholder,
|
||||
normalizeAlertWebhookCustomFields,
|
||||
} from '@/utils/alertWebhookPresentation';
|
||||
import {
|
||||
formField,
|
||||
labelClass,
|
||||
controlClass,
|
||||
formHelpText,
|
||||
formCheckbox,
|
||||
} from '@/components/shared/Form';
|
||||
import { Show } from 'solid-js';
|
||||
|
||||
interface WebhookConfigProps {
|
||||
import type { Webhook } from '@/api/notifications';
|
||||
import { ALERT_WEBHOOK_ADD_LABEL } from '@/utils/alertWebhookPresentation';
|
||||
|
||||
import { WebhookConfigForm } from './WebhookConfigForm';
|
||||
import { WebhookConfigList } from './WebhookConfigList';
|
||||
import { useWebhookConfigState } from './useWebhookConfigState';
|
||||
|
||||
export interface WebhookConfigProps {
|
||||
webhooks: Webhook[];
|
||||
onAdd: (webhook: Omit<Webhook, 'id'>) => void;
|
||||
onUpdate: (webhook: Webhook) => void;
|
||||
@@ -60,701 +16,54 @@ interface WebhookConfigProps {
|
||||
testing?: string | null;
|
||||
}
|
||||
|
||||
type HeaderInput = { id: string; key: string; value: string };
|
||||
|
||||
type CustomFieldInput = HeaderInput & {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
const buildMapFromInputs = (
|
||||
inputs: Array<{ key: string; value: string }>,
|
||||
): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
inputs.forEach(({ key, value }) => {
|
||||
if (key) {
|
||||
map[key] = value;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
export function WebhookConfig(props: WebhookConfigProps) {
|
||||
const [adding, setAdding] = createSignal(false);
|
||||
const [editingId, setEditingId] = createSignal<string | null>(null);
|
||||
const [formData, setFormData] = createSignal<
|
||||
Omit<Webhook, 'id'> & { service: string; payloadTemplate?: string }
|
||||
>({
|
||||
name: '',
|
||||
url: '',
|
||||
method: 'POST',
|
||||
service: 'generic',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
enabled: true,
|
||||
payloadTemplate: '',
|
||||
customFields: {},
|
||||
mention: '',
|
||||
});
|
||||
const [templates, setTemplates] = createSignal<WebhookTemplate[]>([]);
|
||||
const [showServiceDropdown, setShowServiceDropdown] = createSignal(false);
|
||||
|
||||
// Track header inputs separately to avoid focus loss
|
||||
const [headerInputs, setHeaderInputs] = createSignal<HeaderInput[]>([]);
|
||||
const [customFieldInputs, _setCustomFieldInputs] = createSignal<CustomFieldInput[]>([]);
|
||||
|
||||
const setCustomFieldInputs = (inputs: CustomFieldInput[]) => {
|
||||
_setCustomFieldInputs(inputs);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
customFields: buildMapFromInputs(inputs),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateCustomFieldInputs = (updater: (inputs: CustomFieldInput[]) => CustomFieldInput[]) => {
|
||||
_setCustomFieldInputs((prev) => {
|
||||
const next = updater(prev);
|
||||
setFormData((prevForm) => ({
|
||||
...prevForm,
|
||||
customFields: buildMapFromInputs(next),
|
||||
}));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Load webhook templates
|
||||
createEffect(async () => {
|
||||
try {
|
||||
const data = await NotificationsAPI.getWebhookTemplates();
|
||||
setTemplates(data);
|
||||
} catch (err) {
|
||||
logger.error('Failed to load webhook templates:', err);
|
||||
}
|
||||
});
|
||||
|
||||
const saveWebhook = () => {
|
||||
const data = formData();
|
||||
if (!data.name || !data.url) return;
|
||||
|
||||
// Build headers from headerInputs
|
||||
const headers: Record<string, string> = {};
|
||||
headerInputs().forEach((input) => {
|
||||
if (input.key) {
|
||||
headers[input.key] = input.value;
|
||||
}
|
||||
});
|
||||
const customFields = buildMapFromInputs(customFieldInputs());
|
||||
const normalizedCustomFields = normalizeAlertWebhookCustomFields(data.service, customFields);
|
||||
|
||||
if (editingId()) {
|
||||
props.onUpdate({
|
||||
...data,
|
||||
id: editingId()!,
|
||||
headers,
|
||||
service: data.service,
|
||||
template: data.payloadTemplate,
|
||||
customFields: normalizedCustomFields,
|
||||
mention: data.mention,
|
||||
});
|
||||
setEditingId(null);
|
||||
setAdding(false);
|
||||
setHeaderInputs([]);
|
||||
setCustomFieldInputs([]);
|
||||
} else {
|
||||
// onAdd expects a webhook without id, but with service
|
||||
const newWebhook: Omit<Webhook, 'id'> = {
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
method: data.method,
|
||||
headers,
|
||||
enabled: data.enabled,
|
||||
service: data.service,
|
||||
template: data.payloadTemplate,
|
||||
customFields: normalizedCustomFields,
|
||||
mention: data.mention,
|
||||
};
|
||||
props.onAdd(newWebhook);
|
||||
// Reset form and close the adding panel
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
method: 'POST',
|
||||
service: 'generic',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
enabled: true,
|
||||
payloadTemplate: '',
|
||||
customFields: {},
|
||||
mention: '',
|
||||
});
|
||||
setHeaderInputs([]);
|
||||
setCustomFieldInputs([]);
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelForm = () => {
|
||||
setAdding(false);
|
||||
setEditingId(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
method: 'POST',
|
||||
service: 'generic',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
enabled: true,
|
||||
payloadTemplate: '',
|
||||
customFields: {},
|
||||
mention: '',
|
||||
});
|
||||
setHeaderInputs([]);
|
||||
setCustomFieldInputs([]);
|
||||
};
|
||||
|
||||
const editWebhook = (webhook: Webhook) => {
|
||||
if (webhook.id) {
|
||||
setEditingId(webhook.id);
|
||||
}
|
||||
setFormData({
|
||||
...webhook,
|
||||
service: webhook.service || 'generic',
|
||||
payloadTemplate: webhook.template || '',
|
||||
customFields: normalizeAlertWebhookCustomFields(webhook.service || 'generic', webhook.customFields || {}),
|
||||
mention: webhook.mention || '',
|
||||
});
|
||||
// Set up header inputs for editing
|
||||
const headers = webhook.headers || {};
|
||||
setHeaderInputs(
|
||||
Object.entries(headers).map(([key, value], index) => ({
|
||||
id: `header-${Date.now()}-${index}`,
|
||||
key,
|
||||
value,
|
||||
})),
|
||||
);
|
||||
|
||||
const service = webhook.service || 'generic';
|
||||
const existingCustomFields = webhook.customFields || {};
|
||||
setCustomFieldInputs(
|
||||
getAlertWebhookCustomFieldInputs(service, existingCustomFields).map((field, index) => ({
|
||||
id: `custom-${field.key}-${Date.now()}-${index}`,
|
||||
...field,
|
||||
})),
|
||||
);
|
||||
setAdding(true);
|
||||
};
|
||||
|
||||
const selectService = (service: string) => {
|
||||
const template = templates().find((t) => t.service === service);
|
||||
if (template) {
|
||||
setFormData({
|
||||
...formData(),
|
||||
service: template.service,
|
||||
method: template.method,
|
||||
headers: { ...template.headers },
|
||||
name: formData().name || template.name,
|
||||
// Clear the payload template when switching services
|
||||
// Only generic service should have custom payloads
|
||||
payloadTemplate: service === 'generic' ? formData().payloadTemplate : '',
|
||||
});
|
||||
// Update header inputs when switching services
|
||||
const headers = template.headers || {};
|
||||
setHeaderInputs(
|
||||
Object.entries(headers).map(([key, value], index) => ({
|
||||
id: `header-${Date.now()}-${index}`,
|
||||
key,
|
||||
value,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
setFormData({
|
||||
...formData(),
|
||||
service,
|
||||
});
|
||||
}
|
||||
setCustomFieldInputs(
|
||||
getAlertWebhookCustomFieldInputs(service, formData().customFields || {}).map((field, index) => ({
|
||||
id: `custom-${field.key}-${Date.now()}-${index}`,
|
||||
...field,
|
||||
})),
|
||||
);
|
||||
setShowServiceDropdown(false);
|
||||
};
|
||||
|
||||
const currentTemplate = () => templates().find((t) => t.service === formData().service);
|
||||
|
||||
const toggleAllWebhooks = (enabled: boolean) => {
|
||||
props.webhooks.forEach((webhook) => {
|
||||
props.onUpdate({ ...webhook, enabled });
|
||||
});
|
||||
};
|
||||
|
||||
const allEnabled = () => props.webhooks.every((w) => w.enabled);
|
||||
const someEnabled = () => props.webhooks.some((w) => w.enabled);
|
||||
const state = useWebhookConfigState(props);
|
||||
|
||||
return (
|
||||
<div class="space-y-6 min-w-0 w-full">
|
||||
{/* Existing Webhooks List */}
|
||||
<Show when={props.webhooks.length > 0}>
|
||||
<div class="space-y-3 w-full">
|
||||
{/* Quick Actions Bar */}
|
||||
<div class="flex flex-col gap-2 rounded border border-border px-3 py-3 text-xs sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-muted sm:text-sm">
|
||||
{getAlertWebhookSummaryLabel(
|
||||
props.webhooks.filter((w) => w.enabled).length,
|
||||
props.webhooks.length,
|
||||
)}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
|
||||
<button
|
||||
onClick={() => toggleAllWebhooks(false)}
|
||||
disabled={!someEnabled()}
|
||||
class="w-full rounded border px-3 py-1 text-xs transition-colors hover:bg-surface-hover sm:w-auto"
|
||||
>
|
||||
{getAlertWebhookToggleAllLabel(false)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleAllWebhooks(true)}
|
||||
disabled={allEnabled()}
|
||||
class="w-full rounded border border-green-500 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900 sm:w-auto"
|
||||
>
|
||||
{getAlertWebhookToggleAllLabel(true)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<For each={props.webhooks}>
|
||||
{(webhook) => (
|
||||
<div class="w-full px-3 py-3 border border-border text-xs sm:text-sm">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-medium text-base-content">{webhook.name}</span>
|
||||
<button
|
||||
onClick={() => props.onUpdate({ ...webhook, enabled: !webhook.enabled })}
|
||||
class={`rounded border px-3 py-1 text-xs font-medium transition-colors ${webhook.enabled ? 'border-green-500 text-green-700 hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900' : 'border-border text-muted hover:bg-surface-hover'}`}
|
||||
>
|
||||
{getAlertWebhookToggleLabel(webhook.enabled)}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-[11px] text-muted sm:text-xs">
|
||||
<span class="rounded bg-surface-alt px-2 py-0.5 text-base-content">
|
||||
{getAlertWebhookServiceLabelFromTemplates(webhook.service || 'generic', templates())}
|
||||
</span>
|
||||
<span class="rounded bg-surface-alt px-2 py-0.5 text-base-content">
|
||||
{webhook.method}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 break-all text-[11px] font-mono text-muted sm:text-xs">
|
||||
{webhook.url}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2 border-t border-border-subtle pt-2 sm:justify-end w-full">
|
||||
<button
|
||||
onClick={() => webhook.id && props.onTest(webhook.id)}
|
||||
disabled={props.testing === webhook.id || !webhook.enabled}
|
||||
class="rounded border px-3 py-1 text-xs text-base-content transition-colors hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
{getAlertWebhookTestLabel(props.testing === webhook.id)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editWebhook(webhook)}
|
||||
class="rounded border border-blue-300 px-3 py-1 text-xs text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-500 dark:text-blue-300 dark:hover:bg-blue-900"
|
||||
>
|
||||
{ALERT_WEBHOOK_EDIT_LABEL}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => webhook.id && props.onDelete(webhook.id)}
|
||||
class="rounded border border-red-300 px-3 py-1 text-xs text-red-600 transition-colors hover:bg-red-50 dark:border-red-500 dark:text-red-300 dark:hover:bg-red-900"
|
||||
>
|
||||
{ALERT_WEBHOOK_DELETE_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<WebhookConfigList
|
||||
webhooks={props.webhooks}
|
||||
templates={state.templates}
|
||||
testing={props.testing}
|
||||
allEnabled={state.allEnabled}
|
||||
someEnabled={state.someEnabled}
|
||||
toggleAllWebhooks={state.toggleAllWebhooks}
|
||||
onToggleWebhook={(webhook) => props.onUpdate({ ...webhook, enabled: !webhook.enabled })}
|
||||
onTestWebhook={(webhook) => webhook.id && props.onTest(webhook.id)}
|
||||
onEditWebhook={state.editWebhook}
|
||||
onDeleteWebhook={(webhook) => webhook.id && props.onDelete(webhook.id)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
<Show when={adding()}>
|
||||
<div class="space-y-4 text-sm">
|
||||
{/* Service Selection */}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<label class="text-sm font-medium text-base-content">Service Type</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowServiceDropdown(!showServiceDropdown())}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
{getAlertWebhookServiceLabelFromTemplates(formData().service, templates())} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={showServiceDropdown()}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 border border-border px-3 py-2 mb-3 text-xs">
|
||||
<For each={getAlertWebhookServices(templates())}>
|
||||
{(service) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectService(service.id)}
|
||||
class={`px-2 py-1.5 text-left border transition-colors text-xs ${
|
||||
formData().service === service.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900'
|
||||
: 'border-border hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<div class="font-medium text-xs text-base-content">{service.label}</div>
|
||||
<div class="text-[11px] text-muted mt-1">{service.description}</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={currentTemplate()?.instructions}>
|
||||
<div class="mb-3 border-l-2 border-blue-300 pl-3 text-xs leading-relaxed text-blue-800 dark:border-blue-700 dark:text-blue-200">
|
||||
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
{getAlertWebhookSetupInstructionsTitle()}
|
||||
</h4>
|
||||
{currentTemplate()!.instructions}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Basic Configuration */}
|
||||
<div class="grid w-full grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().name}
|
||||
onInput={(e) => setFormData({ ...formData(), name: e.currentTarget.value })}
|
||||
placeholder={getAlertWebhookNamePlaceholder(currentTemplate()?.name)}
|
||||
class={controlClass('px-2 py-1.5')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>HTTP method</label>
|
||||
<select
|
||||
value={formData().method}
|
||||
onChange={(e) => setFormData({ ...formData(), method: e.currentTarget.value })}
|
||||
class={controlClass('px-2 py-1.5 pr-8 appearance-none')}
|
||||
>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData().url}
|
||||
onInput={(e) => setFormData({ ...formData(), url: e.currentTarget.value })}
|
||||
placeholder={getAlertWebhookUrlPlaceholder(currentTemplate()?.urlPattern)}
|
||||
class={controlClass('px-2 py-1.5 font-mono')}
|
||||
/>
|
||||
<p class={formHelpText + ' mt-1'}>
|
||||
Supports template variables like{' '}
|
||||
<code class="font-mono text-[11px] text-muted">
|
||||
{ALERT_WEBHOOK_URL_HELP_TEMPLATE_VARIABLE}
|
||||
</code>
|
||||
. Use{' '}
|
||||
<code class="font-mono text-[11px] text-muted">{ALERT_WEBHOOK_URL_HELP_PATH}</code> or{' '}
|
||||
<code class="font-mono text-[11px] text-muted">{ALERT_WEBHOOK_URL_HELP_QUERY}</code>{' '}
|
||||
to keep dynamic values URL-safe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mention Field - show for supported services */}
|
||||
<Show
|
||||
when={hasAlertWebhookMentionSupportFromTemplates(formData().service, templates())}
|
||||
>
|
||||
<div class={formField}>
|
||||
<label class={labelClass('flex items-center gap-2')}>
|
||||
Mention
|
||||
<span class="text-xs text-muted">{ALERT_WEBHOOK_MENTION_HELP_LABEL}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData().mention || ''}
|
||||
onInput={(e) => setFormData({ ...formData(), mention: e.currentTarget.value })}
|
||||
placeholder={getAlertWebhookMentionPlaceholderFromTemplates(
|
||||
formData().service,
|
||||
templates(),
|
||||
)}
|
||||
class={controlClass('px-2 py-1.5')}
|
||||
/>
|
||||
<Show
|
||||
when={getAlertWebhookMentionHelpFromTemplates(formData().service, templates())}
|
||||
>
|
||||
<p class={formHelpText + ' mt-1'}>
|
||||
{getAlertWebhookMentionHelpFromTemplates(formData().service, templates())}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Custom Payload Template - only show for generic service */}
|
||||
<Show when={formData().service === 'generic'}>
|
||||
<div class={formField}>
|
||||
<label class={labelClass('flex items-center gap-2')}>
|
||||
Custom payload template (JSON)
|
||||
<span class="text-xs text-muted">{ALERT_WEBHOOK_PAYLOAD_HELP_LABEL}</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={formData().payloadTemplate || ''}
|
||||
onInput={(e) =>
|
||||
setFormData({ ...formData(), payloadTemplate: e.currentTarget.value })
|
||||
}
|
||||
placeholder={ALERT_WEBHOOK_PAYLOAD_TEMPLATE_PLACEHOLDER}
|
||||
rows={8}
|
||||
class={controlClass('px-2 py-1.5 text-xs font-mono min-h-[160px]')}
|
||||
/>
|
||||
<p class={formHelpText + ' mt-1'}>
|
||||
Available variables: {ALERT_WEBHOOK_PAYLOAD_VARIABLES}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Custom Fields Section */}
|
||||
<Show when={customFieldInputs().length > 0 || formData().service === 'pushover'}>
|
||||
<div class={formField}>
|
||||
<label class={labelClass('flex items-center gap-2')}>
|
||||
Custom fields
|
||||
<span class="text-xs text-muted">
|
||||
{ALERT_WEBHOOK_CUSTOM_FIELDS_HELP}{' '}
|
||||
<code class="font-mono text-[11px] text-muted">
|
||||
{ALERT_WEBHOOK_CUSTOM_FIELDS_REFERENCE}
|
||||
</code>{' '}
|
||||
in templates
|
||||
</span>
|
||||
</label>
|
||||
<div class="space-y-2 text-xs">
|
||||
<Index each={customFieldInputs()}>
|
||||
{(field, index) => (
|
||||
<div class="flex gap-2 text-xs">
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<Show when={field().label}>
|
||||
<span class="text-[11px] text-muted">{field().label}</span>
|
||||
</Show>
|
||||
<input
|
||||
type="text"
|
||||
value={field().key}
|
||||
disabled={field().required}
|
||||
onInput={(e) => {
|
||||
const newKey = e.currentTarget.value;
|
||||
updateCustomFieldInputs((inputs) => {
|
||||
const next = [...inputs];
|
||||
next[index] = { ...next[index], key: newKey };
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder={ALERT_WEBHOOK_CUSTOM_FIELD_KEY_PLACEHOLDER}
|
||||
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={field().value}
|
||||
onInput={(e) => {
|
||||
const newValue = e.currentTarget.value;
|
||||
updateCustomFieldInputs((inputs) => {
|
||||
const next = [...inputs];
|
||||
next[index] = { ...next[index], value: newValue };
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder={
|
||||
field().placeholder || ALERT_WEBHOOK_CUSTOM_FIELD_VALUE_PLACEHOLDER
|
||||
}
|
||||
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
|
||||
/>
|
||||
<Show when={!field().required}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateCustomFieldInputs((inputs) =>
|
||||
inputs.filter((_, i) => i !== index),
|
||||
);
|
||||
}}
|
||||
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
|
||||
>
|
||||
{ALERT_WEBHOOK_CUSTOM_FIELD_REMOVE_LABEL}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newId = `custom-${Date.now()}-${Math.random()}`;
|
||||
updateCustomFieldInputs((inputs) => [
|
||||
...inputs,
|
||||
{
|
||||
id: newId,
|
||||
key: '',
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
}}
|
||||
class="w-full border border-dashed border-border px-2 py-1 text-xs hover:bg-surface-hover"
|
||||
>
|
||||
{ALERT_WEBHOOK_CUSTOM_FIELD_ADD_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted">{ALERT_WEBHOOK_CUSTOM_FIELDS_PUSHHOVER_HELP}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Custom Headers Section */}
|
||||
<div class={formField}>
|
||||
<label class={labelClass('flex items-center gap-2')}>
|
||||
Custom headers
|
||||
<span class="text-xs text-muted">{ALERT_WEBHOOK_HEADERS_HELP_LABEL}</span>
|
||||
</label>
|
||||
<div class="space-y-2 text-xs">
|
||||
<Index each={headerInputs()}>
|
||||
{(header, index) => (
|
||||
<div class="flex gap-2 text-xs">
|
||||
<input
|
||||
type="text"
|
||||
value={header().key}
|
||||
onInput={(e) => {
|
||||
const newKey = e.currentTarget.value;
|
||||
setHeaderInputs((inputs) => {
|
||||
const newInputs = [...inputs];
|
||||
newInputs[index] = { ...newInputs[index], key: newKey };
|
||||
return newInputs;
|
||||
});
|
||||
}}
|
||||
placeholder={ALERT_WEBHOOK_HEADER_KEY_PLACEHOLDER}
|
||||
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={header().value}
|
||||
onInput={(e) => {
|
||||
const newValue = e.currentTarget.value;
|
||||
setHeaderInputs((inputs) => {
|
||||
const newInputs = [...inputs];
|
||||
newInputs[index] = { ...newInputs[index], value: newValue };
|
||||
return newInputs;
|
||||
});
|
||||
}}
|
||||
placeholder={ALERT_WEBHOOK_HEADER_VALUE_PLACEHOLDER}
|
||||
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHeaderInputs((inputs) => inputs.filter((_, i) => i !== index));
|
||||
}}
|
||||
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
|
||||
>
|
||||
{ALERT_WEBHOOK_HEADER_REMOVE_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newId = `header-${Date.now()}-${Math.random()}`;
|
||||
setHeaderInputs([
|
||||
...headerInputs(),
|
||||
{
|
||||
id: newId,
|
||||
key: '',
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
}}
|
||||
class="w-full border border-dashed border-border px-2 py-1 text-xs hover:bg-surface-hover"
|
||||
>
|
||||
{ALERT_WEBHOOK_HEADER_ADD_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted">{ALERT_WEBHOOK_HEADER_HELP}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm text-base-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData().enabled}
|
||||
onChange={(e) => setFormData({ ...formData(), enabled: e.currentTarget.checked })}
|
||||
class={formCheckbox}
|
||||
/>
|
||||
<span>{ALERT_WEBHOOK_ENABLE_LABEL}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 text-xs">
|
||||
<button
|
||||
onClick={cancelForm}
|
||||
class="px-3 py-1.5 border border-border rounded text-xs hover:"
|
||||
>
|
||||
{ALERT_WEBHOOK_CANCEL_LABEL}
|
||||
</button>
|
||||
<Show when={formData().url && formData().name}>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Test the webhook with current form data
|
||||
// Build headers from headerInputs
|
||||
const headers: Record<string, string> = {};
|
||||
headerInputs().forEach((input) => {
|
||||
if (input.key) {
|
||||
headers[input.key] = input.value;
|
||||
}
|
||||
});
|
||||
const customFields = buildMapFromInputs(customFieldInputs());
|
||||
const { payloadTemplate, ...restFormData } = formData();
|
||||
const testPayload = {
|
||||
...restFormData,
|
||||
headers,
|
||||
customFields,
|
||||
template: payloadTemplate ?? restFormData.template ?? '',
|
||||
};
|
||||
// Use a consistent temporary ID for this form session
|
||||
const tempId = editingId() || 'temp-new-webhook';
|
||||
props.onTest(tempId, testPayload);
|
||||
}}
|
||||
disabled={props.testing === (editingId() || 'temp-new-webhook')}
|
||||
class="px-3 py-1.5 border border-border rounded text-xs hover:bg-slate-100"
|
||||
>
|
||||
{getAlertWebhookTestLabel(
|
||||
props.testing === (editingId() || 'temp-new-webhook'),
|
||||
true,
|
||||
)}
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
onClick={saveWebhook}
|
||||
disabled={!formData().name || !formData().url}
|
||||
class="px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{getAlertWebhookSubmitLabel(Boolean(editingId()))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={state.adding()}>
|
||||
<WebhookConfigForm
|
||||
editingId={state.editingId}
|
||||
formData={state.formData}
|
||||
setFormData={state.setFormData}
|
||||
templates={state.templates}
|
||||
currentTemplate={state.currentTemplate}
|
||||
showServiceDropdown={state.showServiceDropdown}
|
||||
setShowServiceDropdown={state.setShowServiceDropdown}
|
||||
headerInputs={state.headerInputs}
|
||||
customFieldInputs={state.customFieldInputs}
|
||||
selectService={state.selectService}
|
||||
updateHeaderInput={state.updateHeaderInput}
|
||||
removeHeaderInput={state.removeHeaderInput}
|
||||
addHeaderInput={state.addHeaderInput}
|
||||
updateCustomFieldInput={state.updateCustomFieldInput}
|
||||
removeCustomFieldInput={state.removeCustomFieldInput}
|
||||
addCustomFieldInput={state.addCustomFieldInput}
|
||||
cancelForm={state.cancelForm}
|
||||
testWebhookForm={state.testWebhookForm}
|
||||
saveWebhook={state.saveWebhook}
|
||||
testing={props.testing}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Add Webhook Button */}
|
||||
<Show when={!adding()}>
|
||||
<Show when={!state.adding()}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAdding(true);
|
||||
// Initialize with default Content-Type header
|
||||
setHeaderInputs([
|
||||
{
|
||||
id: `header-${Date.now()}-0`,
|
||||
key: 'Content-Type',
|
||||
value: 'application/json',
|
||||
},
|
||||
]);
|
||||
setCustomFieldInputs([]);
|
||||
}}
|
||||
onClick={state.openAddForm}
|
||||
class="w-full border border-dashed border-border px-2 py-1 text-xs text-muted hover:bg-surface-hover"
|
||||
>
|
||||
+ {ALERT_WEBHOOK_ADD_LABEL}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { Index, Show, For } from 'solid-js';
|
||||
import type { Accessor } from 'solid-js';
|
||||
|
||||
import type { WebhookTemplate } from '@/api/notifications';
|
||||
import {
|
||||
ALERT_WEBHOOK_CANCEL_LABEL,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELDS_HELP,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELDS_PUSHHOVER_HELP,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELDS_REFERENCE,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELD_ADD_LABEL,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELD_KEY_PLACEHOLDER,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELD_REMOVE_LABEL,
|
||||
ALERT_WEBHOOK_CUSTOM_FIELD_VALUE_PLACEHOLDER,
|
||||
ALERT_WEBHOOK_ENABLE_LABEL,
|
||||
ALERT_WEBHOOK_HEADER_ADD_LABEL,
|
||||
ALERT_WEBHOOK_HEADER_HELP,
|
||||
ALERT_WEBHOOK_HEADER_KEY_PLACEHOLDER,
|
||||
ALERT_WEBHOOK_HEADER_REMOVE_LABEL,
|
||||
ALERT_WEBHOOK_HEADER_VALUE_PLACEHOLDER,
|
||||
ALERT_WEBHOOK_HEADERS_HELP_LABEL,
|
||||
ALERT_WEBHOOK_MENTION_HELP_LABEL,
|
||||
ALERT_WEBHOOK_PAYLOAD_HELP_LABEL,
|
||||
ALERT_WEBHOOK_PAYLOAD_TEMPLATE_PLACEHOLDER,
|
||||
ALERT_WEBHOOK_PAYLOAD_VARIABLES,
|
||||
ALERT_WEBHOOK_URL_HELP_PATH,
|
||||
ALERT_WEBHOOK_URL_HELP_QUERY,
|
||||
ALERT_WEBHOOK_URL_HELP_TEMPLATE_VARIABLE,
|
||||
getAlertWebhookMentionHelpFromTemplates,
|
||||
getAlertWebhookMentionPlaceholderFromTemplates,
|
||||
getAlertWebhookNamePlaceholder,
|
||||
getAlertWebhookServiceLabelFromTemplates,
|
||||
getAlertWebhookServices,
|
||||
getAlertWebhookSetupInstructionsTitle,
|
||||
getAlertWebhookSubmitLabel,
|
||||
getAlertWebhookTestLabel,
|
||||
getAlertWebhookUrlPlaceholder,
|
||||
hasAlertWebhookMentionSupportFromTemplates,
|
||||
} from '@/utils/alertWebhookPresentation';
|
||||
import {
|
||||
formCheckbox,
|
||||
formField,
|
||||
formHelpText,
|
||||
controlClass,
|
||||
labelClass,
|
||||
} from '@/components/shared/Form';
|
||||
|
||||
import type {
|
||||
CustomFieldInput,
|
||||
HeaderInput,
|
||||
WebhookConfigFormData,
|
||||
} from './useWebhookConfigState';
|
||||
|
||||
type SetterLike<T> = (value: T | ((prev: T) => T)) => T;
|
||||
|
||||
interface WebhookConfigFormProps {
|
||||
editingId: Accessor<string | null>;
|
||||
formData: Accessor<WebhookConfigFormData>;
|
||||
setFormData: SetterLike<WebhookConfigFormData>;
|
||||
templates: Accessor<WebhookTemplate[]>;
|
||||
currentTemplate: Accessor<WebhookTemplate | undefined>;
|
||||
showServiceDropdown: Accessor<boolean>;
|
||||
setShowServiceDropdown: SetterLike<boolean>;
|
||||
headerInputs: Accessor<HeaderInput[]>;
|
||||
customFieldInputs: Accessor<CustomFieldInput[]>;
|
||||
selectService: (service: string) => void;
|
||||
updateHeaderInput: (index: number, patch: Partial<HeaderInput>) => void;
|
||||
removeHeaderInput: (index: number) => void;
|
||||
addHeaderInput: () => void;
|
||||
updateCustomFieldInput: (index: number, patch: Partial<CustomFieldInput>) => void;
|
||||
removeCustomFieldInput: (index: number) => void;
|
||||
addCustomFieldInput: () => void;
|
||||
cancelForm: () => void;
|
||||
testWebhookForm: () => void;
|
||||
saveWebhook: () => void;
|
||||
testing?: string | null;
|
||||
}
|
||||
|
||||
export function WebhookConfigForm(props: WebhookConfigFormProps) {
|
||||
return (
|
||||
<div class="space-y-4 text-sm">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<label class="text-sm font-medium text-base-content">Service Type</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.setShowServiceDropdown((open) => !open)}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
{getAlertWebhookServiceLabelFromTemplates(
|
||||
props.formData().service,
|
||||
props.templates(),
|
||||
)}{' '}
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={props.showServiceDropdown()}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 border border-border px-3 py-2 mb-3 text-xs">
|
||||
<For each={getAlertWebhookServices(props.templates())}>
|
||||
{(service) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.selectService(service.id)}
|
||||
class={`px-2 py-1.5 text-left border transition-colors text-xs ${
|
||||
props.formData().service === service.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900'
|
||||
: 'border-border hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<div class="font-medium text-xs text-base-content">{service.label}</div>
|
||||
<div class="text-[11px] text-muted mt-1">{service.description}</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.currentTemplate()?.instructions}>
|
||||
<div class="mb-3 border-l-2 border-blue-300 pl-3 text-xs leading-relaxed text-blue-800 dark:border-blue-700 dark:text-blue-200">
|
||||
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
{getAlertWebhookSetupInstructionsTitle()}
|
||||
</h4>
|
||||
{props.currentTemplate()!.instructions}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="grid w-full grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.formData().name}
|
||||
onInput={(e) =>
|
||||
props.setFormData((prev) => ({ ...prev, name: e.currentTarget.value }))
|
||||
}
|
||||
placeholder={getAlertWebhookNamePlaceholder(props.currentTemplate()?.name)}
|
||||
class={controlClass('px-2 py-1.5')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>HTTP method</label>
|
||||
<select
|
||||
value={props.formData().method}
|
||||
onChange={(e) =>
|
||||
props.setFormData((prev) => ({ ...prev, method: e.currentTarget.value }))
|
||||
}
|
||||
class={controlClass('px-2 py-1.5 pr-8 appearance-none')}
|
||||
>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={props.formData().url}
|
||||
onInput={(e) =>
|
||||
props.setFormData((prev) => ({ ...prev, url: e.currentTarget.value }))
|
||||
}
|
||||
placeholder={getAlertWebhookUrlPlaceholder(props.currentTemplate()?.urlPattern)}
|
||||
class={controlClass('px-2 py-1.5 font-mono')}
|
||||
/>
|
||||
<p class={formHelpText + ' mt-1'}>
|
||||
Supports template variables like{' '}
|
||||
<code class="font-mono text-[11px] text-muted">
|
||||
{ALERT_WEBHOOK_URL_HELP_TEMPLATE_VARIABLE}
|
||||
</code>
|
||||
. Use{' '}
|
||||
<code class="font-mono text-[11px] text-muted">{ALERT_WEBHOOK_URL_HELP_PATH}</code> or{' '}
|
||||
<code class="font-mono text-[11px] text-muted">{ALERT_WEBHOOK_URL_HELP_QUERY}</code> to
|
||||
keep dynamic values URL-safe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Show when={hasAlertWebhookMentionSupportFromTemplates(props.formData().service, props.templates())}>
|
||||
<div class={formField}>
|
||||
<label class={labelClass('flex items-center gap-2')}>
|
||||
Mention
|
||||
<span class="text-xs text-muted">{ALERT_WEBHOOK_MENTION_HELP_LABEL}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.formData().mention || ''}
|
||||
onInput={(e) =>
|
||||
props.setFormData((prev) => ({ ...prev, mention: e.currentTarget.value }))
|
||||
}
|
||||
placeholder={getAlertWebhookMentionPlaceholderFromTemplates(
|
||||
props.formData().service,
|
||||
props.templates(),
|
||||
)}
|
||||
class={controlClass('px-2 py-1.5')}
|
||||
/>
|
||||
<Show
|
||||
when={getAlertWebhookMentionHelpFromTemplates(
|
||||
props.formData().service,
|
||||
props.templates(),
|
||||
)}
|
||||
>
|
||||
<p class={formHelpText + ' mt-1'}>
|
||||
{getAlertWebhookMentionHelpFromTemplates(
|
||||
props.formData().service,
|
||||
props.templates(),
|
||||
)}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.formData().service === 'generic'}>
|
||||
<div class={formField}>
|
||||
<label class={labelClass('flex items-center gap-2')}>
|
||||
Custom payload template (JSON)
|
||||
<span class="text-xs text-muted">{ALERT_WEBHOOK_PAYLOAD_HELP_LABEL}</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={props.formData().payloadTemplate || ''}
|
||||
onInput={(e) =>
|
||||
props.setFormData((prev) => ({
|
||||
...prev,
|
||||
payloadTemplate: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
placeholder={ALERT_WEBHOOK_PAYLOAD_TEMPLATE_PLACEHOLDER}
|
||||
rows={8}
|
||||
class={controlClass('px-2 py-1.5 text-xs font-mono min-h-[160px]')}
|
||||
/>
|
||||
<p class={formHelpText + ' mt-1'}>
|
||||
Available variables: {ALERT_WEBHOOK_PAYLOAD_VARIABLES}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.customFieldInputs().length > 0 || props.formData().service === 'pushover'}>
|
||||
<div class={formField}>
|
||||
<label class={labelClass('flex items-center gap-2')}>
|
||||
Custom fields
|
||||
<span class="text-xs text-muted">
|
||||
{ALERT_WEBHOOK_CUSTOM_FIELDS_HELP}{' '}
|
||||
<code class="font-mono text-[11px] text-muted">
|
||||
{ALERT_WEBHOOK_CUSTOM_FIELDS_REFERENCE}
|
||||
</code>{' '}
|
||||
in templates
|
||||
</span>
|
||||
</label>
|
||||
<div class="space-y-2 text-xs">
|
||||
<Index each={props.customFieldInputs()}>
|
||||
{(field, index) => (
|
||||
<div class="flex gap-2 text-xs">
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<Show when={field().label}>
|
||||
<span class="text-[11px] text-muted">{field().label}</span>
|
||||
</Show>
|
||||
<input
|
||||
type="text"
|
||||
value={field().key}
|
||||
disabled={field().required}
|
||||
onInput={(e) =>
|
||||
props.updateCustomFieldInput(index, { key: e.currentTarget.value })
|
||||
}
|
||||
placeholder={ALERT_WEBHOOK_CUSTOM_FIELD_KEY_PLACEHOLDER}
|
||||
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={field().value}
|
||||
onInput={(e) =>
|
||||
props.updateCustomFieldInput(index, { value: e.currentTarget.value })
|
||||
}
|
||||
placeholder={
|
||||
field().placeholder || ALERT_WEBHOOK_CUSTOM_FIELD_VALUE_PLACEHOLDER
|
||||
}
|
||||
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
|
||||
/>
|
||||
<Show when={!field().required}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.removeCustomFieldInput(index)}
|
||||
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
|
||||
>
|
||||
{ALERT_WEBHOOK_CUSTOM_FIELD_REMOVE_LABEL}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.addCustomFieldInput}
|
||||
class="w-full border border-dashed border-border px-2 py-1 text-xs hover:bg-surface-hover"
|
||||
>
|
||||
{ALERT_WEBHOOK_CUSTOM_FIELD_ADD_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted">{ALERT_WEBHOOK_CUSTOM_FIELDS_PUSHHOVER_HELP}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class={formField}>
|
||||
<label class={labelClass('flex items-center gap-2')}>
|
||||
Custom headers
|
||||
<span class="text-xs text-muted">{ALERT_WEBHOOK_HEADERS_HELP_LABEL}</span>
|
||||
</label>
|
||||
<div class="space-y-2 text-xs">
|
||||
<Index each={props.headerInputs()}>
|
||||
{(header, index) => (
|
||||
<div class="flex gap-2 text-xs">
|
||||
<input
|
||||
type="text"
|
||||
value={header().key}
|
||||
onInput={(e) => props.updateHeaderInput(index, { key: e.currentTarget.value })}
|
||||
placeholder={ALERT_WEBHOOK_HEADER_KEY_PLACEHOLDER}
|
||||
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={header().value}
|
||||
onInput={(e) =>
|
||||
props.updateHeaderInput(index, { value: e.currentTarget.value })
|
||||
}
|
||||
placeholder={ALERT_WEBHOOK_HEADER_VALUE_PLACEHOLDER}
|
||||
class={controlClass('flex-1 px-2 py-1.5 text-xs font-mono')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.removeHeaderInput(index)}
|
||||
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
|
||||
>
|
||||
{ALERT_WEBHOOK_HEADER_REMOVE_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.addHeaderInput}
|
||||
class="w-full border border-dashed border-border px-2 py-1 text-xs hover:bg-surface-hover"
|
||||
>
|
||||
{ALERT_WEBHOOK_HEADER_ADD_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted">{ALERT_WEBHOOK_HEADER_HELP}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm text-base-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.formData().enabled}
|
||||
onChange={(e) =>
|
||||
props.setFormData((prev) => ({ ...prev, enabled: e.currentTarget.checked }))
|
||||
}
|
||||
class={formCheckbox}
|
||||
/>
|
||||
<span>{ALERT_WEBHOOK_ENABLE_LABEL}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 text-xs">
|
||||
<button
|
||||
onClick={props.cancelForm}
|
||||
class="px-3 py-1.5 border border-border rounded text-xs hover:bg-surface-hover"
|
||||
>
|
||||
{ALERT_WEBHOOK_CANCEL_LABEL}
|
||||
</button>
|
||||
<Show when={props.formData().url && props.formData().name}>
|
||||
<button
|
||||
onClick={props.testWebhookForm}
|
||||
disabled={props.testing === (props.editingId() || 'temp-new-webhook')}
|
||||
class="px-3 py-1.5 border border-border rounded text-xs hover:bg-slate-100"
|
||||
>
|
||||
{getAlertWebhookTestLabel(
|
||||
props.testing === (props.editingId() || 'temp-new-webhook'),
|
||||
true,
|
||||
)}
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
onClick={props.saveWebhook}
|
||||
disabled={!props.formData().name || !props.formData().url}
|
||||
class="px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{getAlertWebhookSubmitLabel(Boolean(props.editingId()))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { For } from 'solid-js';
|
||||
import type { Accessor } from 'solid-js';
|
||||
|
||||
import type { Webhook, WebhookTemplate } from '@/api/notifications';
|
||||
import {
|
||||
ALERT_WEBHOOK_DELETE_LABEL,
|
||||
ALERT_WEBHOOK_EDIT_LABEL,
|
||||
getAlertWebhookServiceLabelFromTemplates,
|
||||
getAlertWebhookSummaryLabel,
|
||||
getAlertWebhookTestLabel,
|
||||
getAlertWebhookToggleAllLabel,
|
||||
getAlertWebhookToggleLabel,
|
||||
} from '@/utils/alertWebhookPresentation';
|
||||
|
||||
interface WebhookConfigListProps {
|
||||
webhooks: Webhook[];
|
||||
templates: Accessor<WebhookTemplate[]>;
|
||||
testing?: string | null;
|
||||
allEnabled: Accessor<boolean>;
|
||||
someEnabled: Accessor<boolean>;
|
||||
toggleAllWebhooks: (enabled: boolean) => void;
|
||||
onToggleWebhook: (webhook: Webhook) => void;
|
||||
onTestWebhook: (webhook: Webhook) => void;
|
||||
onEditWebhook: (webhook: Webhook) => void;
|
||||
onDeleteWebhook: (webhook: Webhook) => void;
|
||||
}
|
||||
|
||||
export function WebhookConfigList(props: WebhookConfigListProps) {
|
||||
return (
|
||||
<div class="space-y-3 w-full">
|
||||
<div class="flex flex-col gap-2 rounded border border-border px-3 py-3 text-xs sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-muted sm:text-sm">
|
||||
{getAlertWebhookSummaryLabel(
|
||||
props.webhooks.filter((webhook) => webhook.enabled).length,
|
||||
props.webhooks.length,
|
||||
)}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
|
||||
<button
|
||||
onClick={() => props.toggleAllWebhooks(false)}
|
||||
disabled={!props.someEnabled()}
|
||||
class="w-full rounded border px-3 py-1 text-xs transition-colors hover:bg-surface-hover sm:w-auto"
|
||||
>
|
||||
{getAlertWebhookToggleAllLabel(false)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.toggleAllWebhooks(true)}
|
||||
disabled={props.allEnabled()}
|
||||
class="w-full rounded border border-green-500 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900 sm:w-auto"
|
||||
>
|
||||
{getAlertWebhookToggleAllLabel(true)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<For each={props.webhooks}>
|
||||
{(webhook) => (
|
||||
<div class="w-full px-3 py-3 border border-border text-xs sm:text-sm">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="font-medium text-base-content">{webhook.name}</span>
|
||||
<button
|
||||
onClick={() => props.onToggleWebhook(webhook)}
|
||||
class={`rounded border px-3 py-1 text-xs font-medium transition-colors ${webhook.enabled ? 'border-green-500 text-green-700 hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900' : 'border-border text-muted hover:bg-surface-hover'}`}
|
||||
>
|
||||
{getAlertWebhookToggleLabel(webhook.enabled)}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-[11px] text-muted sm:text-xs">
|
||||
<span class="rounded bg-surface-alt px-2 py-0.5 text-base-content">
|
||||
{getAlertWebhookServiceLabelFromTemplates(
|
||||
webhook.service || 'generic',
|
||||
props.templates(),
|
||||
)}
|
||||
</span>
|
||||
<span class="rounded bg-surface-alt px-2 py-0.5 text-base-content">
|
||||
{webhook.method}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 break-all text-[11px] font-mono text-muted sm:text-xs">{webhook.url}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2 border-t border-border-subtle pt-2 sm:justify-end w-full">
|
||||
<button
|
||||
onClick={() => props.onTestWebhook(webhook)}
|
||||
disabled={props.testing === webhook.id || !webhook.enabled}
|
||||
class="rounded border px-3 py-1 text-xs text-base-content transition-colors hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
{getAlertWebhookTestLabel(props.testing === webhook.id)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.onEditWebhook(webhook)}
|
||||
class="rounded border border-blue-300 px-3 py-1 text-xs text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-500 dark:text-blue-300 dark:hover:bg-blue-900"
|
||||
>
|
||||
{ALERT_WEBHOOK_EDIT_LABEL}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.onDeleteWebhook(webhook)}
|
||||
class="rounded border border-red-300 px-3 py-1 text-xs text-red-600 transition-colors hover:bg-red-50 dark:border-red-500 dark:text-red-300 dark:hover:bg-red-900"
|
||||
>
|
||||
{ALERT_WEBHOOK_DELETE_LABEL}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { createEffect, createSignal } from 'solid-js';
|
||||
import type { Accessor } from 'solid-js';
|
||||
|
||||
import {
|
||||
NotificationsAPI,
|
||||
type Webhook,
|
||||
type WebhookTemplate,
|
||||
} from '@/api/notifications';
|
||||
import { logger } from '@/utils/logger';
|
||||
import {
|
||||
getAlertWebhookCustomFieldInputs,
|
||||
normalizeAlertWebhookCustomFields,
|
||||
} from '@/utils/alertWebhookPresentation';
|
||||
|
||||
import type { WebhookConfigProps } from './WebhookConfig';
|
||||
|
||||
export type HeaderInput = {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CustomFieldInput = HeaderInput & {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type WebhookConfigFormData = Omit<Webhook, 'id'> & {
|
||||
service: string;
|
||||
payloadTemplate?: string;
|
||||
};
|
||||
|
||||
type SetterLike<T> = (value: T | ((prev: T) => T)) => T;
|
||||
|
||||
const createDefaultFormData = (): WebhookConfigFormData => ({
|
||||
name: '',
|
||||
url: '',
|
||||
method: 'POST',
|
||||
service: 'generic',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
enabled: true,
|
||||
payloadTemplate: '',
|
||||
customFields: {},
|
||||
mention: '',
|
||||
});
|
||||
|
||||
const buildMapFromInputs = (
|
||||
inputs: Array<{ key: string; value: string }>,
|
||||
): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
inputs.forEach(({ key, value }) => {
|
||||
if (key) {
|
||||
map[key] = value;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const createHeaderInput = (index: number, key = '', value = ''): HeaderInput => ({
|
||||
id: `header-${Date.now()}-${index}`,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
|
||||
const createCustomFieldInput = (
|
||||
field: Omit<CustomFieldInput, 'id'>,
|
||||
index: number,
|
||||
): CustomFieldInput => ({
|
||||
id: `custom-${field.key || 'field'}-${Date.now()}-${index}`,
|
||||
...field,
|
||||
});
|
||||
|
||||
export interface WebhookConfigState {
|
||||
adding: Accessor<boolean>;
|
||||
editingId: Accessor<string | null>;
|
||||
formData: Accessor<WebhookConfigFormData>;
|
||||
templates: Accessor<WebhookTemplate[]>;
|
||||
currentTemplate: Accessor<WebhookTemplate | undefined>;
|
||||
showServiceDropdown: Accessor<boolean>;
|
||||
headerInputs: Accessor<HeaderInput[]>;
|
||||
customFieldInputs: Accessor<CustomFieldInput[]>;
|
||||
allEnabled: Accessor<boolean>;
|
||||
someEnabled: Accessor<boolean>;
|
||||
setFormData: SetterLike<WebhookConfigFormData>;
|
||||
setShowServiceDropdown: SetterLike<boolean>;
|
||||
openAddForm: () => void;
|
||||
cancelForm: () => void;
|
||||
editWebhook: (webhook: Webhook) => void;
|
||||
selectService: (service: string) => void;
|
||||
saveWebhook: () => void;
|
||||
testWebhookForm: () => void;
|
||||
toggleAllWebhooks: (enabled: boolean) => void;
|
||||
updateHeaderInput: (index: number, patch: Partial<HeaderInput>) => void;
|
||||
removeHeaderInput: (index: number) => void;
|
||||
addHeaderInput: () => void;
|
||||
updateCustomFieldInput: (index: number, patch: Partial<CustomFieldInput>) => void;
|
||||
removeCustomFieldInput: (index: number) => void;
|
||||
addCustomFieldInput: () => void;
|
||||
}
|
||||
|
||||
export function useWebhookConfigState(props: WebhookConfigProps): WebhookConfigState {
|
||||
const [adding, setAdding] = createSignal(false);
|
||||
const [editingId, setEditingId] = createSignal<string | null>(null);
|
||||
const [formData, setFormData] = createSignal<WebhookConfigFormData>(createDefaultFormData());
|
||||
const [templates, setTemplates] = createSignal<WebhookTemplate[]>([]);
|
||||
const [showServiceDropdown, setShowServiceDropdown] = createSignal(false);
|
||||
const [headerInputs, setHeaderInputs] = createSignal<HeaderInput[]>([]);
|
||||
const [customFieldInputs, _setCustomFieldInputs] = createSignal<CustomFieldInput[]>([]);
|
||||
|
||||
const setCustomFieldInputs = (inputs: CustomFieldInput[]) => {
|
||||
_setCustomFieldInputs(inputs);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
customFields: buildMapFromInputs(inputs),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateCustomFieldInputs = (updater: (inputs: CustomFieldInput[]) => CustomFieldInput[]) => {
|
||||
_setCustomFieldInputs((prev) => {
|
||||
const next = updater(prev);
|
||||
setFormData((prevForm) => ({
|
||||
...prevForm,
|
||||
customFields: buildMapFromInputs(next),
|
||||
}));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setAdding(false);
|
||||
setEditingId(null);
|
||||
setFormData(createDefaultFormData());
|
||||
setHeaderInputs([]);
|
||||
setCustomFieldInputs([]);
|
||||
setShowServiceDropdown(false);
|
||||
};
|
||||
|
||||
createEffect(async () => {
|
||||
try {
|
||||
const data = await NotificationsAPI.getWebhookTemplates();
|
||||
setTemplates(data);
|
||||
} catch (err) {
|
||||
logger.error('Failed to load webhook templates:', err);
|
||||
}
|
||||
});
|
||||
|
||||
const currentTemplate = () => templates().find((template) => template.service === formData().service);
|
||||
|
||||
const toggleAllWebhooks = (enabled: boolean) => {
|
||||
props.webhooks.forEach((webhook) => {
|
||||
props.onUpdate({ ...webhook, enabled });
|
||||
});
|
||||
};
|
||||
|
||||
const allEnabled = () => props.webhooks.every((webhook) => webhook.enabled);
|
||||
const someEnabled = () => props.webhooks.some((webhook) => webhook.enabled);
|
||||
|
||||
const openAddForm = () => {
|
||||
setAdding(true);
|
||||
setEditingId(null);
|
||||
setFormData(createDefaultFormData());
|
||||
setShowServiceDropdown(false);
|
||||
setHeaderInputs([createHeaderInput(0, 'Content-Type', 'application/json')]);
|
||||
setCustomFieldInputs([]);
|
||||
};
|
||||
|
||||
const cancelForm = () => {
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const editWebhook = (webhook: Webhook) => {
|
||||
if (webhook.id) {
|
||||
setEditingId(webhook.id);
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...webhook,
|
||||
service: webhook.service || 'generic',
|
||||
payloadTemplate: webhook.template || '',
|
||||
customFields: normalizeAlertWebhookCustomFields(
|
||||
webhook.service || 'generic',
|
||||
webhook.customFields || {},
|
||||
),
|
||||
mention: webhook.mention || '',
|
||||
});
|
||||
|
||||
const headers = webhook.headers || {};
|
||||
setHeaderInputs(
|
||||
Object.entries(headers).map(([key, value], index) => createHeaderInput(index, key, value)),
|
||||
);
|
||||
|
||||
const service = webhook.service || 'generic';
|
||||
const existingCustomFields = webhook.customFields || {};
|
||||
setCustomFieldInputs(
|
||||
getAlertWebhookCustomFieldInputs(service, existingCustomFields).map((field, index) =>
|
||||
createCustomFieldInput(field, index),
|
||||
),
|
||||
);
|
||||
|
||||
setShowServiceDropdown(false);
|
||||
setAdding(true);
|
||||
};
|
||||
|
||||
const selectService = (service: string) => {
|
||||
const template = templates().find((candidate) => candidate.service === service);
|
||||
|
||||
if (template) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
service: template.service,
|
||||
method: template.method,
|
||||
headers: { ...template.headers },
|
||||
name: prev.name || template.name,
|
||||
payloadTemplate: service === 'generic' ? prev.payloadTemplate : '',
|
||||
}));
|
||||
|
||||
const headers = template.headers || {};
|
||||
setHeaderInputs(
|
||||
Object.entries(headers).map(([key, value], index) => createHeaderInput(index, key, value)),
|
||||
);
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
service,
|
||||
}));
|
||||
}
|
||||
|
||||
setCustomFieldInputs(
|
||||
getAlertWebhookCustomFieldInputs(service, formData().customFields || {}).map((field, index) =>
|
||||
createCustomFieldInput(field, index),
|
||||
),
|
||||
);
|
||||
setShowServiceDropdown(false);
|
||||
};
|
||||
|
||||
const saveWebhook = () => {
|
||||
const data = formData();
|
||||
if (!data.name || !data.url) return;
|
||||
|
||||
const headers = buildMapFromInputs(headerInputs());
|
||||
const customFields = buildMapFromInputs(customFieldInputs());
|
||||
const normalizedCustomFields = normalizeAlertWebhookCustomFields(data.service, customFields);
|
||||
|
||||
if (editingId()) {
|
||||
props.onUpdate({
|
||||
...data,
|
||||
id: editingId()!,
|
||||
headers,
|
||||
service: data.service,
|
||||
template: data.payloadTemplate,
|
||||
customFields: normalizedCustomFields,
|
||||
mention: data.mention,
|
||||
});
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
props.onAdd({
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
method: data.method,
|
||||
headers,
|
||||
enabled: data.enabled,
|
||||
service: data.service,
|
||||
template: data.payloadTemplate,
|
||||
customFields: normalizedCustomFields,
|
||||
mention: data.mention,
|
||||
});
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const testWebhookForm = () => {
|
||||
const data = formData();
|
||||
const headers = buildMapFromInputs(headerInputs());
|
||||
const customFields = buildMapFromInputs(customFieldInputs());
|
||||
const { payloadTemplate, ...restFormData } = data;
|
||||
const testPayload = {
|
||||
...restFormData,
|
||||
headers,
|
||||
customFields,
|
||||
template: payloadTemplate ?? restFormData.template ?? '',
|
||||
};
|
||||
const tempId = editingId() || 'temp-new-webhook';
|
||||
props.onTest(tempId, testPayload);
|
||||
};
|
||||
|
||||
const updateHeaderInput = (index: number, patch: Partial<HeaderInput>) => {
|
||||
setHeaderInputs((inputs) => {
|
||||
const next = [...inputs];
|
||||
next[index] = { ...next[index], ...patch };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeHeaderInput = (index: number) => {
|
||||
setHeaderInputs((inputs) => inputs.filter((_, currentIndex) => currentIndex !== index));
|
||||
};
|
||||
|
||||
const addHeaderInput = () => {
|
||||
setHeaderInputs((inputs) => [...inputs, createHeaderInput(inputs.length)]);
|
||||
};
|
||||
|
||||
const updateCustomFieldInput = (index: number, patch: Partial<CustomFieldInput>) => {
|
||||
updateCustomFieldInputs((inputs) => {
|
||||
const next = [...inputs];
|
||||
next[index] = { ...next[index], ...patch };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeCustomFieldInput = (index: number) => {
|
||||
updateCustomFieldInputs((inputs) =>
|
||||
inputs.filter((_, currentIndex) => currentIndex !== index),
|
||||
);
|
||||
};
|
||||
|
||||
const addCustomFieldInput = () => {
|
||||
updateCustomFieldInputs((inputs) => [
|
||||
...inputs,
|
||||
createCustomFieldInput({ key: '', value: '' }, inputs.length),
|
||||
]);
|
||||
};
|
||||
|
||||
return {
|
||||
adding,
|
||||
editingId,
|
||||
formData,
|
||||
templates,
|
||||
currentTemplate,
|
||||
showServiceDropdown,
|
||||
headerInputs,
|
||||
customFieldInputs,
|
||||
allEnabled,
|
||||
someEnabled,
|
||||
setFormData,
|
||||
setShowServiceDropdown,
|
||||
openAddForm,
|
||||
cancelForm,
|
||||
editWebhook,
|
||||
selectService,
|
||||
saveWebhook,
|
||||
testWebhookForm,
|
||||
toggleAllWebhooks,
|
||||
updateHeaderInput,
|
||||
removeHeaderInput,
|
||||
addHeaderInput,
|
||||
updateCustomFieldInput,
|
||||
removeCustomFieldInput,
|
||||
addCustomFieldInput,
|
||||
};
|
||||
}
|
||||
@@ -341,6 +341,9 @@ 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 webhookConfigSource from '@/components/Alerts/WebhookConfig.tsx?raw';
|
||||
import webhookConfigFormSource from '@/components/Alerts/WebhookConfigForm.tsx?raw';
|
||||
import webhookConfigListSource from '@/components/Alerts/WebhookConfigList.tsx?raw';
|
||||
import webhookConfigStateSource from '@/components/Alerts/useWebhookConfigState.ts?raw';
|
||||
import alertActivationPresentationSource from '@/utils/alertActivationPresentation.ts?raw';
|
||||
import alertFrequencyPresentationSource from '@/utils/alertFrequencyPresentation.ts?raw';
|
||||
import alertSeverityPresentationSource from '@/utils/alertSeverityPresentation.ts?raw';
|
||||
@@ -3884,20 +3887,15 @@ describe('frontend resource type boundaries', () => {
|
||||
});
|
||||
|
||||
it('keeps alert webhook service vocabulary and action copy in a shared presentation utility', () => {
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookServices(templates())');
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookServiceLabelFromTemplates');
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookCustomFieldInputs');
|
||||
expect(webhookConfigSource).toContain('normalizeAlertWebhookCustomFields');
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookSummaryLabel');
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookToggleAllLabel');
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookToggleLabel');
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookNamePlaceholder');
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookUrlPlaceholder');
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookMentionPlaceholderFromTemplates');
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookMentionHelpFromTemplates');
|
||||
expect(webhookConfigSource).toContain('hasAlertWebhookMentionSupportFromTemplates');
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookTestLabel');
|
||||
expect(webhookConfigSource).toContain('getAlertWebhookSubmitLabel');
|
||||
expect(webhookConfigSource).toContain('useWebhookConfigState');
|
||||
expect(webhookConfigSource).toContain('WebhookConfigList');
|
||||
expect(webhookConfigSource).toContain('WebhookConfigForm');
|
||||
expect(webhookConfigSource).not.toContain('NotificationsAPI.getWebhookTemplates');
|
||||
expect(webhookConfigSource).not.toContain('const saveWebhook = () => {');
|
||||
expect(webhookConfigSource).not.toContain('const editWebhook = (webhook: Webhook) => {');
|
||||
expect(webhookConfigSource).not.toContain(
|
||||
'const toggleAllWebhooks = (enabled: boolean) => {',
|
||||
);
|
||||
expect(webhookConfigSource).not.toContain('Custom webhook endpoint');
|
||||
expect(webhookConfigSource).not.toContain('Discord server webhook');
|
||||
expect(webhookConfigSource).not.toContain('My Webhook');
|
||||
@@ -3915,6 +3913,24 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(webhookConfigSource).not.toContain('Enable All');
|
||||
expect(webhookConfigSource).not.toContain('Disable All');
|
||||
expect(webhookConfigSource).not.toContain('Enable this webhook');
|
||||
expect(webhookConfigListSource).toContain('getAlertWebhookServiceLabelFromTemplates');
|
||||
expect(webhookConfigListSource).toContain('getAlertWebhookSummaryLabel');
|
||||
expect(webhookConfigListSource).toContain('getAlertWebhookToggleAllLabel');
|
||||
expect(webhookConfigListSource).toContain('getAlertWebhookToggleLabel');
|
||||
expect(webhookConfigListSource).toContain('getAlertWebhookTestLabel');
|
||||
expect(webhookConfigFormSource).toContain('getAlertWebhookServices');
|
||||
expect(webhookConfigFormSource).toContain('getAlertWebhookNamePlaceholder');
|
||||
expect(webhookConfigFormSource).toContain('getAlertWebhookUrlPlaceholder');
|
||||
expect(webhookConfigFormSource).toContain(
|
||||
'getAlertWebhookMentionPlaceholderFromTemplates',
|
||||
);
|
||||
expect(webhookConfigFormSource).toContain('getAlertWebhookMentionHelpFromTemplates');
|
||||
expect(webhookConfigFormSource).toContain('hasAlertWebhookMentionSupportFromTemplates');
|
||||
expect(webhookConfigFormSource).toContain('getAlertWebhookTestLabel');
|
||||
expect(webhookConfigFormSource).toContain('getAlertWebhookSubmitLabel');
|
||||
expect(webhookConfigStateSource).toContain('NotificationsAPI.getWebhookTemplates');
|
||||
expect(webhookConfigStateSource).toContain('getAlertWebhookCustomFieldInputs');
|
||||
expect(webhookConfigStateSource).toContain('normalizeAlertWebhookCustomFields');
|
||||
expect(alertWebhookPresentationSource).toContain(
|
||||
'export function getAlertWebhookServiceLabelFromTemplates',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user