// Visual rule editor — author a ceremony's decision policy as ordered
// route cards (when → then), compile to Rego live, and save it as a
// new policy revision. The deferred "Rule IR" authoring layer, wired
// to the running daemon.
import { useMemo, useState } from "react";
import {
type Condition,
type Effect,
type RuleIR,
type Route,
compileToRego,
conditionsFor,
effectsFor,
irToEnglish,
} from "@/lib/rule-ir";
function condId(c: Condition): string {
return typeof c === "string" ? c : (Object.keys(c)[0] ?? "");
}
function condArg(c: Condition): string | undefined {
return typeof c === "string" ? undefined : Object.values(c)[0];
}
export function RuleEditor({
purpose,
pkg,
initial,
onSave,
onCancel,
saving,
}: {
purpose: string;
pkg: string;
initial: RuleIR;
onSave: (rego: string) => void;
onCancel: () => void;
saving: boolean;
}) {
const [ir, setIr] = useState<RuleIR>(initial);
const [view, setView] = useState<"english" | "rego">("english");
const vocab = useMemo(() => conditionsFor(purpose), [purpose]);
const effects = useMemo(() => effectsFor(purpose), [purpose]);
const rego = useMemo(() => compileToRego(ir, pkg), [ir, pkg]);
const english = useMemo(() => irToEnglish(ir), [ir]);
const setRoutes = (routes: Route[]) => setIr({ ...ir, routes });
const patchRoute = (i: number, patch: Partial<Route>) =>
setRoutes(ir.routes.map((r, j) => (j === i ? { ...r, ...patch } : r)));
const move = (i: number, dir: -1 | 1) => {
const j = i + dir;
if (j < 0 || j >= ir.routes.length) return;
const next = [...ir.routes];
const a = next[i]!;
next[i] = next[j]!;
next[j] = a;
setRoutes(next);
};
return (
<div className="rule-editor">
<div className="rule-cards">
{ir.routes.map((route, i) => (
<RouteCard
key={i}
route={route}
index={i}
count={ir.routes.length}
vocab={vocab}
effects={effects}
onChange={(patch) => patchRoute(i, patch)}
onMove={(dir) => move(i, dir)}
onRemove={() =>
setRoutes(ir.routes.filter((_, j) => j !== i))
}
/>
))}
<button
type="button"
className="rule-add-route"
onClick={() =>
setRoutes([
...ir.routes,
{
name: "New route",
when: { all: ["always"] },
then: { effect: "refer", with: { queue: "moderator" } },
},
])
}
>
+ Add route
</button>
<p className="cer-sub" style={{ fontSize: "var(--text-xs)" }}>
Routes are first-match, top to bottom. A structural{" "}
<code>deny</code> is always appended as the backstop.
</p>
</div>
<div className="rule-preview">
<div className="rule-view-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={view === "english"}
className={view === "english" ? "on" : ""}
onClick={() => setView("english")}
>
Plain English
</button>
<button
type="button"
role="tab"
aria-selected={view === "rego"}
className={view === "rego" ? "on" : ""}
onClick={() => setView("rego")}
>
Compiled Rego
</button>
</div>
{view === "english" ? (
<EnglishView lines={english} />
) : (
<pre className="cer-policy" style={{ maxHeight: 360 }}>
{rego}
</pre>
)}
<div className="rule-actions">
<button
type="button"
className="cer-run"
disabled={saving}
onClick={() => onSave(rego)}
>
{saving ? "Saving…" : "Save as new revision ▸"}
</button>
<button
type="button"
className="rule-cancel"
onClick={onCancel}
disabled={saving}
>
Cancel
</button>
</div>
</div>
</div>
);
}
// A readable summary of a policy's routes — one line per route, the
// effect colour-coded, first-match framing. Shared by the editor
// preview and the read-only active-policy view.
export function EnglishView({
lines,
}: {
lines: ReturnType<typeof irToEnglish>;
}) {
return (
<ul className="rule-english">
{lines.map((l, i) => (
<li key={i} className={`eng-line eff-${l.effect}`}>
<span className="eng-name">{l.name}</span>
<span className="eng-text">{l.text}</span>
</li>
))}
</ul>
);
}
function RouteCard({
route,
index,
count,
vocab,
effects,
onChange,
onMove,
onRemove,
}: {
route: Route;
index: number;
count: number;
vocab: ReturnType<typeof conditionsFor>;
effects: ReturnType<typeof effectsFor>;
onChange: (patch: Partial<Route>) => void;
onMove: (dir: -1 | 1) => void;
onRemove: () => void;
}) {
const [pendingCond, setPendingCond] = useState(vocab[0]?.id ?? "always");
const [pendingArg, setPendingArg] = useState("");
const pendingDef = vocab.find((v) => v.id === pendingCond);
const eff = effects.find((e) => e.effect === route.then.effect) ?? effects[0]!;
const effField = eff.field;
const effValue = effField
? formatWithValue(route.then.with[effField.key])
: "";
const addCond = () => {
if (!pendingDef) return;
const cond: Condition = pendingDef.arg
? { [pendingDef.id]: pendingArg }
: pendingDef.id;
onChange({ when: { all: [...route.when.all, cond] } });
setPendingArg("");
};
const removeCond = (i: number) =>
onChange({ when: { all: route.when.all.filter((_, j) => j !== i) } });
const setEffect = (effect: Effect["effect"]) => {
const field = effects.find((e) => e.effect === effect)?.field;
onChange({ then: { effect, with: field ? { [field.key]: "" } : {} } });
};
const setEffValue = (raw: string) => {
if (!effField) return;
const value =
effField.key === "fields" || effField.key === "needs"
? raw.split(",").map((s) => s.trim()).filter(Boolean)
: raw;
onChange({ then: { effect: route.then.effect, with: { [effField.key]: value } } });
};
return (
<div className={`rule-card eff-${route.then.effect}`}>
<div className="rule-card-head">
<span className="rule-pri">{index + 1}</span>
<input
className="rule-name"
value={route.name}
onChange={(e) => onChange({ name: e.target.value })}
/>
<div className="rule-tools">
<button
type="button"
disabled={index === 0}
onClick={() => onMove(-1)}
title="Move up"
>
↑
</button>
<button
type="button"
disabled={index === count - 1}
onClick={() => onMove(1)}
title="Move down"
>
↓
</button>
<button type="button" onClick={onRemove} title="Remove route">
×
</button>
</div>
</div>
<div className="rule-when">
<span className="rule-kw">when all of</span>
{route.when.all.map((c, i) => {
const def = vocab.find((v) => v.id === condId(c));
const arg = condArg(c);
return (
<span className="rule-cond" key={i}>
{def?.label ?? condId(c)}
{arg ? <b> {arg}</b> : null}
<span className="rule-x" onClick={() => removeCond(i)}>
×
</span>
</span>
);
})}
</div>
<div className="rule-add-cond">
<select
value={pendingCond}
onChange={(e) => setPendingCond(e.target.value)}
>
{vocab.map((v) => (
<option key={v.id} value={v.id}>
{v.label}
</option>
))}
</select>
{pendingDef?.arg && (
<input
className="rule-arg"
placeholder={pendingDef.arg.placeholder}
value={pendingArg}
onChange={(e) => setPendingArg(e.target.value)}
/>
)}
<button type="button" onClick={addCond}>
+ condition
</button>
</div>
<div className="rule-then">
<span className="rule-kw">then</span>
<select
value={route.then.effect}
onChange={(e) => setEffect(e.target.value as Effect["effect"])}
>
{effects.map((e) => (
<option key={e.effect} value={e.effect}>
{e.label}
</option>
))}
</select>
{effField && (
<input
className="rule-arg"
placeholder={effField.placeholder}
value={effValue}
onChange={(e) => setEffValue(e.target.value)}
/>
)}
</div>
</div>
);
}
function formatWithValue(v: unknown): string {
if (Array.isArray(v)) return v.join(",");
if (typeof v === "string") return v;
return "";
}