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:
@@ -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';
|
import type { FormState } from './form-state';
|
||||||
|
|
||||||
|
type TargetKind = FormState['targets'][number]['kind'];
|
||||||
|
|
||||||
export function NotifyStep({
|
export function NotifyStep({
|
||||||
form: _form,
|
form,
|
||||||
setForm: _setForm,
|
setForm,
|
||||||
ruleId: _ruleId,
|
ruleId,
|
||||||
}: {
|
}: {
|
||||||
form: FormState;
|
form: FormState;
|
||||||
setForm: (f: FormState) => void;
|
setForm: (f: FormState) => void;
|
||||||
ruleId?: string;
|
ruleId?: string;
|
||||||
}) {
|
}) {
|
||||||
return <div>Notify step — 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) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
updateWebhook(i, { headerOverrides: [...w.headerOverrides, { key: '', value: '' }] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Header override
|
||||||
|
</Button>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user