feat(ui/alerts): rule editor wizard shell + form-state module

Wizard navigates 5 steps (scope/condition/trigger/notify/review) with
per-step validation. form-state module is the single source of truth for
the rule form; initialForm/toRequest/validateStep are unit-tested (6
tests). Step components are stubbed and will be implemented in Tasks
20-24. prefillFromPromotion is a thin wrapper in this commit; Task 24
rewrites it to compute scope-adjustment warnings.

Deviation notes:
 - FormState.targets uses {kind, targetId} to match AlertRuleTarget DTO
   field names (plan draft had targetKind).
 - toRequest casts through Record<string, unknown> so the spread over
   the Partial<AlertCondition> union typechecks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-20 13:57:30 +02:00
parent 7e91459cd6
commit 334e815c25
10 changed files with 472 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
import type { FormState } from './form-state';
export function ConditionStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) {
return <div>Condition step &mdash; TODO Task 21</div>;
}

View File

@@ -0,0 +1,13 @@
import type { FormState } from './form-state';
export function NotifyStep({
form: _form,
setForm: _setForm,
ruleId: _ruleId,
}: {
form: FormState;
setForm: (f: FormState) => void;
ruleId?: string;
}) {
return <div>Notify step &mdash; TODO Task 23</div>;
}

View File

@@ -0,0 +1,11 @@
import type { FormState } from './form-state';
export function ReviewStep({
form: _form,
setForm: _setForm,
}: {
form: FormState;
setForm?: (f: FormState) => void;
}) {
return <div>Review step &mdash; TODO Task 24</div>;
}

View File

@@ -1,3 +1,154 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import { Button, SectionHeader, useToast } from '@cameleer/design-system';
import { PageLoader } from '../../../components/PageLoader';
import {
useAlertRule,
useCreateAlertRule,
useUpdateAlertRule,
} from '../../../api/queries/alertRules';
import {
initialForm,
toRequest,
validateStep,
WIZARD_STEPS,
type FormState,
type WizardStep,
} from './form-state';
import { ScopeStep } from './ScopeStep';
import { ConditionStep } from './ConditionStep';
import { TriggerStep } from './TriggerStep';
import { NotifyStep } from './NotifyStep';
import { ReviewStep } from './ReviewStep';
import { prefillFromPromotion } from './promotion-prefill';
import css from './wizard.module.css';
const STEP_LABELS: Record<WizardStep, string> = {
scope: '1. Scope',
condition: '2. Condition',
trigger: '3. Trigger',
notify: '4. Notify',
review: '5. Review',
};
export default function RuleEditorWizard() {
return <div>RuleEditorWizard coming soon</div>;
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const [search] = useSearchParams();
const { toast } = useToast();
const isEdit = !!id;
const existingQuery = useAlertRule(isEdit ? id : undefined);
// Promotion prefill uses a separate query against the source env. In
// Plan 03 we reuse `useAlertRule` (current-env scoped); cross-env
// fetching is handled server-side in the promote query hook when wired.
const promoteFrom = search.get('promoteFrom') ?? undefined;
const promoteRuleId = search.get('ruleId') ?? undefined;
const sourceRuleQuery = useAlertRule(promoteFrom ? promoteRuleId : undefined);
const [step, setStep] = useState<WizardStep>('scope');
const [form, setForm] = useState<FormState | null>(null);
// Initialize form once the existing or source rule loads.
useEffect(() => {
if (form) return;
if (isEdit && existingQuery.data) {
setForm(initialForm(existingQuery.data));
return;
}
if (promoteFrom && sourceRuleQuery.data) {
setForm(prefillFromPromotion(sourceRuleQuery.data));
return;
}
if (!isEdit && !promoteFrom) {
setForm(initialForm());
}
}, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data]);
const create = useCreateAlertRule();
const update = useUpdateAlertRule(id ?? '');
if (!form) return <PageLoader />;
const idx = WIZARD_STEPS.indexOf(step);
const errors = validateStep(step, form);
const onNext = () => {
if (errors.length > 0) {
toast({ title: 'Fix validation errors before continuing', description: errors.join(' \u00b7 '), variant: 'error' });
return;
}
if (idx < WIZARD_STEPS.length - 1) setStep(WIZARD_STEPS[idx + 1]);
};
const onBack = () => {
if (idx > 0) setStep(WIZARD_STEPS[idx - 1]);
};
const onSave = async () => {
try {
if (isEdit) {
await update.mutateAsync(toRequest(form));
toast({ title: 'Rule updated', description: form.name, variant: 'success' });
} else {
await create.mutateAsync(toRequest(form));
toast({ title: 'Rule created', description: form.name, variant: 'success' });
}
navigate('/alerts/rules');
} catch (e) {
toast({ title: 'Save failed', description: String(e), variant: 'error' });
}
};
const body =
step === 'scope' ? (
<ScopeStep form={form} setForm={setForm} />
) : step === 'condition' ? (
<ConditionStep form={form} setForm={setForm} />
) : step === 'trigger' ? (
<TriggerStep form={form} setForm={setForm} ruleId={id} />
) : step === 'notify' ? (
<NotifyStep form={form} setForm={setForm} ruleId={id} />
) : (
<ReviewStep form={form} setForm={setForm} />
);
return (
<div className={css.wizard}>
<div className={css.header}>
<SectionHeader>{isEdit ? `Edit rule: ${form.name}` : 'New alert rule'}</SectionHeader>
{promoteFrom && (
<div className={css.promoteBanner}>
Promoting from <code>{promoteFrom}</code> &mdash; review and adjust, then save.
</div>
)}
</div>
<nav className={css.steps}>
{WIZARD_STEPS.map((s, i) => (
<button
key={s}
className={`${css.step} ${step === s ? css.stepActive : ''} ${i < idx ? css.stepDone : ''}`}
onClick={() => setStep(s)}
>
{STEP_LABELS[s]}
</button>
))}
</nav>
<div className={css.stepBody}>{body}</div>
<div className={css.footer}>
<Button variant="secondary" onClick={onBack} disabled={idx === 0}>
Back
</Button>
{idx < WIZARD_STEPS.length - 1 ? (
<Button variant="primary" onClick={onNext}>
Next
</Button>
) : (
<Button variant="primary" onClick={onSave} disabled={create.isPending || update.isPending}>
{isEdit ? 'Save changes' : 'Create rule'}
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import type { FormState } from './form-state';
export function ScopeStep({ form: _form, setForm: _setForm }: { form: FormState; setForm: (f: FormState) => void }) {
return <div>Scope step &mdash; TODO Task 20</div>;
}

View File

@@ -0,0 +1,13 @@
import type { FormState } from './form-state';
export function TriggerStep({
form: _form,
setForm: _setForm,
ruleId: _ruleId,
}: {
form: FormState;
setForm: (f: FormState) => void;
ruleId?: string;
}) {
return <div>Trigger step &mdash; TODO Task 22</div>;
}

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { initialForm, toRequest, validateStep } from './form-state';
describe('initialForm', () => {
it('defaults to env-wide ROUTE_METRIC with safe intervals', () => {
const f = initialForm();
expect(f.scopeKind).toBe('env');
expect(f.conditionKind).toBe('ROUTE_METRIC');
expect(f.evaluationIntervalSeconds).toBeGreaterThanOrEqual(5);
expect(f.enabled).toBe(true);
});
});
describe('toRequest', () => {
it('strips empty scope fields for env-wide rules', () => {
const f = initialForm();
f.name = 'test';
const req = toRequest(f);
const scope = (req.condition as unknown as { scope: Record<string, string | undefined> }).scope;
expect(scope.appSlug).toBeUndefined();
expect(scope.routeId).toBeUndefined();
expect(scope.agentId).toBeUndefined();
});
it('includes appSlug for app/route/agent scopes', () => {
const f = initialForm();
f.scopeKind = 'app';
f.appSlug = 'orders';
const req = toRequest(f);
const scope = (req.condition as unknown as { scope: Record<string, string | undefined> }).scope;
expect(scope.appSlug).toBe('orders');
});
});
describe('validateStep', () => {
it('flags blank name on scope step', () => {
expect(validateStep('scope', initialForm())).toContain('Name is required.');
});
it('flags app requirement for app-scope', () => {
const f = initialForm();
f.name = 'x';
f.scopeKind = 'app';
expect(validateStep('scope', f).some((e) => /App is required/.test(e))).toBe(true);
});
it('flags intervals below floor on trigger step', () => {
const f = initialForm();
f.evaluationIntervalSeconds = 1;
expect(validateStep('trigger', f).some((e) => /Evaluation interval/.test(e))).toBe(true);
});
});

View File

@@ -0,0 +1,151 @@
import type {
AlertRuleRequest,
AlertRuleResponse,
ConditionKind,
AlertCondition,
} from '../../../api/queries/alertRules';
export type WizardStep = 'scope' | 'condition' | 'trigger' | 'notify' | 'review';
export const WIZARD_STEPS: WizardStep[] = ['scope', 'condition', 'trigger', 'notify', 'review'];
export interface FormState {
name: string;
description: string;
severity: 'CRITICAL' | 'WARNING' | 'INFO';
enabled: boolean;
// Scope (radio: env-wide | app | route | agent)
scopeKind: 'env' | 'app' | 'route' | 'agent';
appSlug: string;
routeId: string;
agentId: string;
conditionKind: ConditionKind;
condition: Partial<AlertCondition>;
evaluationIntervalSeconds: number;
forDurationSeconds: number;
reNotifyMinutes: number;
notificationTitleTmpl: string;
notificationMessageTmpl: string;
webhooks: Array<{
outboundConnectionId: string;
bodyOverride: string;
headerOverrides: Array<{ key: string; value: string }>;
}>;
targets: Array<{ kind: 'USER' | 'GROUP' | 'ROLE'; targetId: string }>;
}
export function initialForm(existing?: AlertRuleResponse): FormState {
if (!existing) {
return {
name: '',
description: '',
severity: 'WARNING',
enabled: true,
scopeKind: 'env',
appSlug: '',
routeId: '',
agentId: '',
conditionKind: 'ROUTE_METRIC',
condition: { kind: 'ROUTE_METRIC' } as Partial<AlertCondition>,
evaluationIntervalSeconds: 60,
forDurationSeconds: 0,
reNotifyMinutes: 60,
notificationTitleTmpl: '{{rule.name}} is firing',
notificationMessageTmpl: 'Alert {{alert.id}} fired at {{alert.firedAt}}',
webhooks: [],
targets: [],
};
}
const scope = ((existing.condition as { scope?: { appSlug?: string; routeId?: string; agentId?: string } } | undefined)?.scope) ?? {};
const scopeKind: FormState['scopeKind'] = scope.agentId
? 'agent'
: scope.routeId
? 'route'
: scope.appSlug
? 'app'
: 'env';
return {
name: existing.name ?? '',
description: existing.description ?? '',
severity: (existing.severity ?? 'WARNING') as FormState['severity'],
enabled: existing.enabled ?? true,
scopeKind,
appSlug: scope.appSlug ?? '',
routeId: scope.routeId ?? '',
agentId: scope.agentId ?? '',
conditionKind: (existing.conditionKind ?? 'ROUTE_METRIC') as ConditionKind,
condition: (existing.condition ?? { kind: existing.conditionKind }) as Partial<AlertCondition>,
evaluationIntervalSeconds: existing.evaluationIntervalSeconds ?? 60,
forDurationSeconds: existing.forDurationSeconds ?? 0,
reNotifyMinutes: existing.reNotifyMinutes ?? 60,
notificationTitleTmpl: existing.notificationTitleTmpl ?? '{{rule.name}} is firing',
notificationMessageTmpl: existing.notificationMessageTmpl ?? 'Alert {{alert.id}} fired at {{alert.firedAt}}',
webhooks: (existing.webhooks ?? []).map((w) => ({
outboundConnectionId: (w.outboundConnectionId ?? '') as string,
bodyOverride: w.bodyOverride ?? '',
headerOverrides: Object.entries((w.headerOverrides ?? {}) as Record<string, string>)
.map(([key, value]) => ({ key, value })),
})),
targets: (existing.targets ?? []).map((t) => ({
kind: (t.kind ?? 'USER') as 'USER' | 'GROUP' | 'ROLE',
targetId: t.targetId ?? '',
})),
};
}
export function toRequest(f: FormState): AlertRuleRequest {
const scope: Record<string, string | undefined> = {};
if (f.scopeKind === 'app' || f.scopeKind === 'route' || f.scopeKind === 'agent') scope.appSlug = f.appSlug || undefined;
if (f.scopeKind === 'route') scope.routeId = f.routeId || undefined;
if (f.scopeKind === 'agent') scope.agentId = f.agentId || undefined;
const condition = { ...(f.condition as Record<string, unknown>), kind: f.conditionKind, scope } as unknown as AlertCondition;
return {
name: f.name,
description: f.description || undefined,
severity: f.severity,
enabled: f.enabled,
conditionKind: f.conditionKind,
condition,
evaluationIntervalSeconds: f.evaluationIntervalSeconds,
forDurationSeconds: f.forDurationSeconds,
reNotifyMinutes: f.reNotifyMinutes,
notificationTitleTmpl: f.notificationTitleTmpl,
notificationMessageTmpl: f.notificationMessageTmpl,
webhooks: f.webhooks.map((w) => ({
outboundConnectionId: w.outboundConnectionId,
bodyOverride: w.bodyOverride || undefined,
headerOverrides: Object.fromEntries(w.headerOverrides.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value])),
})),
targets: f.targets.map((t) => ({ kind: t.kind, targetId: t.targetId })),
} as AlertRuleRequest;
}
export function validateStep(step: WizardStep, f: FormState): string[] {
const errs: string[] = [];
if (step === 'scope') {
if (!f.name.trim()) errs.push('Name is required.');
if (f.scopeKind !== 'env' && !f.appSlug.trim()) errs.push('App is required for app/route/agent scope.');
if (f.scopeKind === 'route' && !f.routeId.trim()) errs.push('Route id is required for route scope.');
if (f.scopeKind === 'agent' && !f.agentId.trim()) errs.push('Agent id is required for agent scope.');
}
if (step === 'condition') {
if (!f.conditionKind) errs.push('Condition kind is required.');
}
if (step === 'trigger') {
if (f.evaluationIntervalSeconds < 5) errs.push('Evaluation interval must be \u2265 5 s.');
if (f.forDurationSeconds < 0) errs.push('For-duration must be \u2265 0.');
if (f.reNotifyMinutes < 0) errs.push('Re-notify cadence must be \u2265 0.');
}
if (step === 'notify') {
if (!f.notificationTitleTmpl.trim()) errs.push('Notification title template is required.');
if (!f.notificationMessageTmpl.trim()) errs.push('Notification message template is required.');
}
return errs;
}

View File

@@ -0,0 +1,15 @@
import { initialForm, type FormState } from './form-state';
import type { AlertRuleResponse } from '../../../api/queries/alertRules';
/**
* Prefill the wizard form from a source rule being promoted from another env.
*
* Task 19 scaffolding: reuses the edit-prefill path and renames the rule.
* Task 24 rewrites this to compute scope-adjustment warnings and return
* `{ form, warnings }`.
*/
export function prefillFromPromotion(source: AlertRuleResponse): FormState {
const f = initialForm(source);
f.name = `${source.name ?? 'rule'} (copy)`;
return f;
}

View File

@@ -0,0 +1,57 @@
.wizard {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.promoteBanner {
padding: 8px 12px;
background: var(--amber-bg, rgba(255, 180, 0, 0.12));
border: 1px solid var(--amber);
border-radius: 6px;
font-size: 13px;
}
.steps {
display: flex;
gap: 8px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.step {
background: none;
border: none;
padding: 8px 12px;
border-bottom: 2px solid transparent;
cursor: pointer;
color: var(--muted);
font-size: 13px;
}
.stepActive {
color: var(--fg);
border-bottom-color: var(--accent);
}
.stepDone {
color: var(--fg);
}
.stepBody {
min-height: 320px;
}
.footer {
display: flex;
justify-content: space-between;
}