feat(ui/alerts): ReviewStep + promotion prefill warnings

Review step dumps a human summary plus raw request JSON, and (when a
setter is supplied) offers an Enabled-on-save Toggle. Promotion prefill
now returns {form, warnings}: clears agent IDs (per-env), flags missing
apps in target env, and flags webhook connections not allowed in target
env. 4 Vitest cases cover copy-name, agent clear, app-missing, and
webhook-not-allowed paths.

The wizard now consumes {form, warnings}; Task 25 renders the warnings
banner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-20 14:04:04 +02:00
parent 816096f4d1
commit 3963ea5591
4 changed files with 186 additions and 16 deletions

View File

@@ -1,11 +1,62 @@
import type { FormState } from './form-state';
import { Toggle } from '@cameleer/design-system';
import { toRequest, type FormState } from './form-state';
export function ReviewStep({
form: _form,
setForm: _setForm,
form,
setForm,
}: {
form: FormState;
setForm?: (f: FormState) => void;
}) {
return <div>Review step &mdash; TODO Task 24</div>;
const req = toRequest(form);
return (
<div style={{ display: 'grid', gap: 12, maxWidth: 720 }}>
<div>
<strong>Name:</strong> {form.name}
</div>
<div>
<strong>Severity:</strong> {form.severity}
</div>
<div>
<strong>Scope:</strong> {form.scopeKind}
{form.scopeKind !== 'env' &&
` (app=${form.appSlug}${form.routeId ? `, route=${form.routeId}` : ''}${form.agentId ? `, agent=${form.agentId}` : ''})`}
</div>
<div>
<strong>Condition kind:</strong> {form.conditionKind}
</div>
<div>
<strong>Intervals:</strong> eval {form.evaluationIntervalSeconds}s &middot; for {form.forDurationSeconds}s &middot; re-notify {form.reNotifyMinutes}m
</div>
<div>
<strong>Targets:</strong> {form.targets.length}
</div>
<div>
<strong>Webhooks:</strong> {form.webhooks.length}
</div>
{setForm && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Toggle
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
label="Enabled on save"
/>
</div>
)}
<details>
<summary>Raw request JSON</summary>
<pre
style={{
fontSize: 11,
background: 'var(--code-bg)',
padding: 8,
borderRadius: 6,
overflow: 'auto',
}}
>
{JSON.stringify(req, null, 2)}
</pre>
</details>
</div>
);
}

View File

@@ -58,7 +58,8 @@ export default function RuleEditorWizard() {
return;
}
if (promoteFrom && sourceRuleQuery.data) {
setForm(prefillFromPromotion(sourceRuleQuery.data));
const { form: prefilled } = prefillFromPromotion(sourceRuleQuery.data);
setForm(prefilled);
return;
}
if (!isEdit && !promoteFrom) {

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { prefillFromPromotion } from './promotion-prefill';
import type { AlertRuleResponse } from '../../../api/queries/alertRules';
function fakeRule(overrides: Partial<AlertRuleResponse> = {}): AlertRuleResponse {
return {
id: '11111111-1111-1111-1111-111111111111',
environmentId: '22222222-2222-2222-2222-222222222222',
name: 'High error rate',
description: undefined,
severity: 'CRITICAL',
enabled: true,
conditionKind: 'ROUTE_METRIC',
condition: {
kind: 'RouteMetricCondition',
scope: { appSlug: 'orders' },
} as unknown as AlertRuleResponse['condition'],
evaluationIntervalSeconds: 60,
forDurationSeconds: 0,
reNotifyMinutes: 60,
notificationTitleTmpl: '{{rule.name}}',
notificationMessageTmpl: 'msg',
webhooks: [],
targets: [],
createdAt: '2026-04-01T00:00:00Z',
createdBy: 'alice',
updatedAt: '2026-04-01T00:00:00Z',
updatedBy: 'alice',
...overrides,
};
}
describe('prefillFromPromotion', () => {
it('appends "(copy)" to name', () => {
const { form } = prefillFromPromotion(fakeRule());
expect(form.name).toBe('High error rate (copy)');
});
it('warns + clears agentId when source rule is agent-scoped', () => {
const { form, warnings } = prefillFromPromotion(
fakeRule({
conditionKind: 'AGENT_STATE',
condition: {
kind: 'AgentStateCondition',
scope: { appSlug: 'orders', agentId: 'orders-0' },
state: 'DEAD',
forSeconds: 60,
} as unknown as AlertRuleResponse['condition'],
}),
);
expect(form.agentId).toBe('');
expect(warnings.find((w) => w.field === 'scope.agentId')).toBeTruthy();
});
it('warns if app does not exist in target env', () => {
const { warnings } = prefillFromPromotion(fakeRule(), { targetEnvAppSlugs: ['other-app'] });
expect(warnings.find((w) => w.field === 'scope.appSlug')).toBeTruthy();
});
it('warns if webhook connection is not allowed in target env', () => {
const rule = fakeRule({
webhooks: [
{
id: 'w1',
outboundConnectionId: 'conn-prod',
bodyOverride: undefined,
headerOverrides: {},
},
],
});
const { warnings } = prefillFromPromotion(rule, { targetEnvAllowedConnectionIds: ['conn-dev'] });
expect(warnings.find((w) => w.field.startsWith('webhooks['))).toBeTruthy();
});
});

View File

@@ -1,15 +1,59 @@
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;
export interface PrefillWarning {
field: string;
message: string;
}
export interface PrefillOptions {
targetEnvAppSlugs?: string[];
/** IDs of outbound connections allowed in the target env. */
targetEnvAllowedConnectionIds?: string[];
}
/**
* Client-side prefill when promoting a rule from another env. Emits warnings for
* fields that cross env boundaries (agent IDs, apps missing in target env,
* outbound connections not allowed in target env).
*/
export function prefillFromPromotion(
source: AlertRuleResponse,
opts: PrefillOptions = {},
): { form: FormState; warnings: PrefillWarning[] } {
const form = initialForm(source);
form.name = `${source.name ?? 'rule'} (copy)`;
const warnings: PrefillWarning[] = [];
// Agent IDs are per-env, can't transfer.
if (form.agentId) {
warnings.push({
field: 'scope.agentId',
message: `Agent \`${form.agentId}\` is specific to the source env \u2014 cleared for target env.`,
});
form.agentId = '';
if (form.scopeKind === 'agent') form.scopeKind = 'app';
}
// App slug: warn if not present in target env.
if (form.appSlug && opts.targetEnvAppSlugs && !opts.targetEnvAppSlugs.includes(form.appSlug)) {
warnings.push({
field: 'scope.appSlug',
message: `App \`${form.appSlug}\` does not exist in the target env. Update before saving.`,
});
}
// Webhook connections: warn if connection is not allowed in target env.
if (opts.targetEnvAllowedConnectionIds) {
for (const w of form.webhooks) {
if (!opts.targetEnvAllowedConnectionIds.includes(w.outboundConnectionId)) {
warnings.push({
field: `webhooks[${w.outboundConnectionId}]`,
message: `Outbound connection is not allowed in the target env \u2014 remove or pick another before saving.`,
});
}
}
}
return { form, warnings };
}