ui(alerts): ReviewStep — render-preview pane for existing rules
Wire up the existing POST /alerts/rules/{id}/render-preview endpoint
so rule authors can preview their Mustache-templated notification
before saving. Available in edit mode only (new rules require save
first — endpoint is id-bound). Matches the njams gap: their rules
builder ships no in-builder preview and operators compensate with
trial-and-error save/retry.
Implementation notes:
- ReviewStep gains an optional `ruleId` prop; when present, a
"Preview notification" button calls `useRenderPreview` (the
existing TanStack mutation in api/queries/alertRules.ts) and
renders title + message in a titled, read-only pane styled like
a notification card.
- Errors surface as a DS Alert (variant=error) beneath the button.
- `RuleEditorWizard` passes `ruleId={id}` through — mirrors the
existing TriggerStep / NotifyStep wiring.
- No stateless (/render-preview without id) variant exists on the
backend, so for new rules the button is simply omitted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Alert, Toggle } from '@cameleer/design-system';
|
import { Alert, Button, Toggle } from '@cameleer/design-system';
|
||||||
|
import { useRenderPreview } from '../../../api/queries/alertRules';
|
||||||
|
import { describeApiError } from '../../../api/errors';
|
||||||
import { toRequest, type FormState } from './form-state';
|
import { toRequest, type FormState } from './form-state';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,12 +26,36 @@ export function computeSaveBlockReason(form: FormState): string | null {
|
|||||||
export function ReviewStep({
|
export function ReviewStep({
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
|
ruleId,
|
||||||
}: {
|
}: {
|
||||||
form: FormState;
|
form: FormState;
|
||||||
setForm?: (f: FormState) => void;
|
setForm?: (f: FormState) => void;
|
||||||
|
/**
|
||||||
|
* Present only in edit mode. When absent the notification-preview button is
|
||||||
|
* hidden, because the backend `/render-preview` endpoint is id-bound and
|
||||||
|
* has no stateless variant — rendering against an unsaved draft would
|
||||||
|
* require a new endpoint and is explicitly out of scope here.
|
||||||
|
*/
|
||||||
|
ruleId?: string;
|
||||||
}) {
|
}) {
|
||||||
const req = toRequest(form);
|
const req = toRequest(form);
|
||||||
const saveBlockReason = useMemo(() => computeSaveBlockReason(form), [form]);
|
const saveBlockReason = useMemo(() => computeSaveBlockReason(form), [form]);
|
||||||
|
|
||||||
|
const previewMutation = useRenderPreview();
|
||||||
|
const [preview, setPreview] = useState<{ title: string; message: string } | null>(null);
|
||||||
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onPreview = async () => {
|
||||||
|
setPreviewError(null);
|
||||||
|
try {
|
||||||
|
const res = await previewMutation.mutateAsync({ id: ruleId!, req: {} });
|
||||||
|
setPreview({ title: res.title ?? '', message: res.message ?? '' });
|
||||||
|
} catch (e) {
|
||||||
|
setPreview(null);
|
||||||
|
setPreviewError(describeApiError(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 12, maxWidth: 720 }}>
|
<div style={{ display: 'grid', gap: 12, maxWidth: 720 }}>
|
||||||
{saveBlockReason && (
|
{saveBlockReason && (
|
||||||
@@ -69,6 +95,57 @@ export function ReviewStep({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ruleId && (
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onPreview}
|
||||||
|
disabled={previewMutation.isPending}
|
||||||
|
>
|
||||||
|
{previewMutation.isPending ? 'Rendering\u2026' : 'Preview notification'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{previewError && (
|
||||||
|
<Alert variant="error" title="Preview failed">
|
||||||
|
{previewError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{preview && (
|
||||||
|
<div
|
||||||
|
aria-label="Rendered notification preview"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
background: 'var(--surface-raised, var(--surface))',
|
||||||
|
display: 'grid',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
Rendered notification preview
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||||
|
{preview.title || <em style={{ color: 'var(--text-muted)' }}>(empty title)</em>}
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preview.message || '(empty message)'}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Raw request JSON</summary>
|
<summary>Raw request JSON</summary>
|
||||||
<pre
|
<pre
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default function RuleEditorWizard() {
|
|||||||
) : step === 'notify' ? (
|
) : step === 'notify' ? (
|
||||||
<NotifyStep form={form} setForm={setForm} ruleId={id} />
|
<NotifyStep form={form} setForm={setForm} ruleId={id} />
|
||||||
) : (
|
) : (
|
||||||
<ReviewStep form={form} setForm={setForm} />
|
<ReviewStep form={form} setForm={setForm} ruleId={id} />
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user