shadimage
approvals-ui
componentvalidation-panelsource: ok · refreshed

Validation panel

Lists policy issues with severities: structural errors that should block activation and best-practice warnings (segregation of duties, single approver on high value, unassigned seats). Click an issue to focus the step on the canvas.

npx shadcn add "https://approvals-ui.vercel.app/r/validation-panel.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

badgehttps://approvals-ui.vercel.app/r/approvals-core.json

Files1 · source cached 10/8/2026, 12:24:50 AM

components/approvals-ui/validation-panel.tsx
"use client";

import { CircleCheck, OctagonX, TriangleAlert } from "lucide-react";

import type { PolicyIssue } from "@/lib/approvals-ui/validate";

import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";

export type ValidationPanelProps = {
  issues: PolicyIssue[];
  /** Called with a step id when an issue is clicked: wire it to the canvas focus. */
  onFocusStep?: (stepId: string) => void;
  className?: string;
};

export const ValidationPanel = ({ issues, onFocusStep, className }: ValidationPanelProps) => {
  if (issues.length === 0) {
    return (
      <div className={cn("flex items-center gap-2 rounded-lg border p-3", className)}>
        <CircleCheck className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
        <p className="text-muted-foreground text-xs">No issues. The policy is safe to activate.</p>
      </div>
    );
  }

  const errors = issues.filter((i) => i.severity === "error").length;
  const warnings = issues.length - errors;

  return (
    <div className={cn("space-y-2", className)}>
      <div className="flex items-center gap-1.5">
        {errors > 0 && (
          <Badge variant="destructive" className="text-[10px]">
            {errors} error{errors === 1 ? "" : "s"}
          </Badge>
        )}
        {warnings > 0 && (
          <Badge
            variant="secondary"
            className="border-amber-500/40 bg-amber-500/10 text-[10px] text-amber-700 dark:text-amber-400"
          >
            {warnings} warning{warnings === 1 ? "" : "s"}
          </Badge>
        )}
      </div>
      <ul className="space-y-1">
        {issues.map((issue) => {
          const firstStepId = issue.stepIds[0];
          const clickable = onFocusStep && firstStepId !== undefined;
          // validatePolicy dedupes on code + sorted stepIds and each rule emits
          // its stepIds in a deterministic order, so code + stepIds is a stable
          // unique identity for the issue (no array index needed).
          const key = `${issue.code}::${issue.stepIds.join(",")}`;
          return (
            <li key={key}>
              <button
                type="button"
                disabled={!clickable}
                onClick={() => clickable && onFocusStep(firstStepId)}
                className={cn(
                  "flex w-full items-start gap-2 rounded-md p-2 text-left",
                  clickable && "hover:bg-muted/60 cursor-pointer transition-colors"
                )}
              >
                {issue.severity === "error" ? (
                  <OctagonX className="text-destructive mt-0.5 size-3.5 shrink-0" />
                ) : (
                  <TriangleAlert className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
                )}
                <span className="min-w-0 space-y-0.5">
                  <span className="block text-xs leading-snug">{issue.message}</span>
                  <span className="text-muted-foreground block font-mono text-[10px]">
                    {issue.code}
                  </span>
                </span>
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

More from approvals-ui

Browse all components in this library →