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';
|
||||
|
||||
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 — 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