feat(ui/alerts): NotifyStep (MustacheEditor for title/message/body, targets, webhook bindings)

Title and message use MustacheEditor with kind-specific autocomplete.
Preview button posts to the render-preview endpoint and shows rendered
title/message inline. Targets combine users/groups/roles into a unified
Badge pill list. Webhook picker filters to outbound connections allowed
in the current env (spec 6, allowed_environment_ids). Header overrides
use plain Input rather than MustacheEditor for now.

Deviations:
 - RenderPreviewRequest is Record<string, never>, so we send {} instead
   of {titleTemplate, messageTemplate}; backend resolves from rule state.
 - RenderPreviewResponse has {title, message} (plan draft used
   renderedTitle/renderedMessage).
 - Button size="sm" not "small" (DS only accepts sm|md).
 - Target kind field renamed from targetKind to kind to match
   AlertRuleTarget DTO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-20 14:02:26 +02:00
parent d42a6ca6a8
commit 816096f4d1

View File

@@ -1,13 +1,250 @@
import { useState } from 'react';
import { Badge, Button, FormField, Input, Select, useToast } from '@cameleer/design-system';
import { MustacheEditor } from '../../../components/MustacheEditor/MustacheEditor';
import { useUsers, useGroups, useRoles } from '../../../api/queries/admin/rbac';
import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections';
import { useSelectedEnv } from '../../../api/queries/alertMeta';
import { useRenderPreview } from '../../../api/queries/alertRules';
import type { FormState } from './form-state';
type TargetKind = FormState['targets'][number]['kind'];
export function NotifyStep({
form: _form,
setForm: _setForm,
ruleId: _ruleId,
form,
setForm,
ruleId,
}: {
form: FormState;
setForm: (f: FormState) => void;
ruleId?: string;
}) {
return <div>Notify step &mdash; TODO Task 23</div>;
const env = useSelectedEnv();
const { data: users } = useUsers(true);
const { data: groups } = useGroups(true);
const { data: roles } = useRoles(true);
const { data: connections } = useOutboundConnections();
const preview = useRenderPreview();
const { toast } = useToast();
const [lastPreview, setLastPreview] = useState<string | null>(null);
// Filter connections to those that allow the current env.
const availableConnections = (connections ?? []).filter(
(c) => c.allowedEnvironmentIds.length === 0 || (!!env && c.allowedEnvironmentIds.includes(env)),
);
const onPreview = async () => {
if (!ruleId) {
toast({ title: 'Save rule first to preview', variant: 'error' });
return;
}
try {
const res = await preview.mutateAsync({ id: ruleId, req: {} });
setLastPreview(`TITLE:\n${res.title ?? ''}\n\nMESSAGE:\n${res.message ?? ''}`);
} catch (e) {
toast({ title: 'Preview failed', description: String(e), variant: 'error' });
}
};
const addTarget = (kind: TargetKind, targetId: string) => {
if (!targetId) return;
if (form.targets.some((t) => t.kind === kind && t.targetId === targetId)) return;
setForm({ ...form, targets: [...form.targets, { kind, targetId }] });
};
const removeTarget = (idx: number) => {
setForm({ ...form, targets: form.targets.filter((_, i) => i !== idx) });
};
const addWebhook = (outboundConnectionId: string) => {
setForm({
...form,
webhooks: [...form.webhooks, { outboundConnectionId, bodyOverride: '', headerOverrides: [] }],
});
};
const removeWebhook = (idx: number) => {
setForm({ ...form, webhooks: form.webhooks.filter((_, i) => i !== idx) });
};
const updateWebhook = (idx: number, patch: Partial<FormState['webhooks'][number]>) => {
setForm({
...form,
webhooks: form.webhooks.map((w, i) => (i === idx ? { ...w, ...patch } : w)),
});
};
return (
<div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
<MustacheEditor
label="Notification title"
value={form.notificationTitleTmpl}
onChange={(v) => setForm({ ...form, notificationTitleTmpl: v })}
kind={form.conditionKind}
singleLine
/>
<MustacheEditor
label="Notification message"
value={form.notificationMessageTmpl}
onChange={(v) => setForm({ ...form, notificationMessageTmpl: v })}
kind={form.conditionKind}
minHeight={120}
/>
<div>
<Button variant="secondary" onClick={onPreview} disabled={preview.isPending}>
Preview rendered output
</Button>
{!ruleId && (
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--muted)' }}>
Save the rule first to preview rendered output.
</p>
)}
{lastPreview && (
<pre
style={{
marginTop: 8,
padding: 8,
background: 'var(--code-bg)',
borderRadius: 6,
fontSize: 12,
whiteSpace: 'pre-wrap',
}}
>
{lastPreview}
</pre>
)}
</div>
<FormField label="Notification targets">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{form.targets.map((t, i) => (
<Badge
key={`${t.kind}:${t.targetId}`}
label={`${t.kind}: ${t.targetId}`}
variant="outlined"
onRemove={() => removeTarget(i)}
/>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Select
value=""
onChange={(e) => {
addTarget('USER', e.target.value);
e.target.value = '';
}}
options={[
{ value: '', label: '+ User' },
...(users ?? []).map((u) => ({ value: u.userId, label: u.displayName ?? u.userId })),
]}
/>
<Select
value=""
onChange={(e) => {
addTarget('GROUP', e.target.value);
e.target.value = '';
}}
options={[
{ value: '', label: '+ Group' },
...(groups ?? []).map((g) => ({ value: g.id, label: g.name })),
]}
/>
<Select
value=""
onChange={(e) => {
addTarget('ROLE', e.target.value);
e.target.value = '';
}}
options={[
{ value: '', label: '+ Role' },
...(roles ?? []).map((r) => ({ value: r.name, label: r.name })),
]}
/>
</div>
</FormField>
<FormField label="Webhook destinations (outbound connections)">
<Select
value=""
onChange={(e) => {
if (e.target.value) addWebhook(e.target.value);
e.target.value = '';
}}
options={[
{ value: '', label: '+ Add webhook' },
...availableConnections.map((c) => ({ value: c.id, label: c.name })),
]}
/>
{form.webhooks.map((w, i) => {
const conn = availableConnections.find((c) => c.id === w.outboundConnectionId);
return (
<div
key={`${w.outboundConnectionId}-${i}`}
style={{
padding: 12,
border: '1px solid var(--border)',
borderRadius: 6,
marginTop: 8,
display: 'grid',
gap: 8,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong>{conn?.name ?? w.outboundConnectionId}</strong>
<Button size="sm" variant="secondary" onClick={() => removeWebhook(i)}>
Remove
</Button>
</div>
<MustacheEditor
label="Body override (optional)"
value={w.bodyOverride}
onChange={(v) => updateWebhook(i, { bodyOverride: v })}
kind={form.conditionKind}
placeholder="Leave empty to use connection default"
minHeight={80}
/>
<FormField label="Header overrides">
{w.headerOverrides.map((h, hi) => (
<div key={hi} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
<Input
value={h.key}
placeholder="Header name"
onChange={(e) => {
const heads = [...w.headerOverrides];
heads[hi] = { ...heads[hi], key: e.target.value };
updateWebhook(i, { headerOverrides: heads });
}}
/>
<Input
value={h.value}
placeholder="Mustache value"
onChange={(e) => {
const heads = [...w.headerOverrides];
heads[hi] = { ...heads[hi], value: e.target.value };
updateWebhook(i, { headerOverrides: heads });
}}
/>
<Button
size="sm"
variant="secondary"
onClick={() =>
updateWebhook(i, { headerOverrides: w.headerOverrides.filter((_, x) => x !== hi) })
}
>
&times;
</Button>
</div>
))}
<Button
size="sm"
variant="secondary"
onClick={() =>
updateWebhook(i, { headerOverrides: [...w.headerOverrides, { key: '', value: '' }] })
}
>
+ Header override
</Button>
</FormField>
</div>
);
})}
</FormField>
</div>
);
}