/** * Git diff style report between an uploaded record and the official * Redrob `candidate_schema.json`. * * Visual contract: * • `match` → neutral grey line. * • `missing` (schema requires it but upload lacks it) → red `-` line. * • `extra` (upload provided a field the schema doesn't declare) → green `+` line. * * The component is deliberately self-contained: it only depends on Tailwind * + our existing `diff` npm package for the side-by-side stringified record * diff, so it can be dropped into any error toast. */ import React, { useMemo, useState } from "react"; import { diffJson } from "diff"; import type { DiffLine, SchemaDiffPayload, SchemaErrorItem, ValidationReport, } from "../types"; import { useScrollIsolation } from "./useScrollIsolation"; interface Props { report?: ValidationReport; diff?: SchemaDiffPayload; /** Optional human-friendly message above the diff (e.g. server error). */ message?: string; } export const SchemaDiff: React.FC = ({ report, diff, message }) => { const [tab, setTab] = useState<"errors" | "fields" | "raw">("errors"); const firstRow = report?.errors_by_row?.[0]; const allErrors: SchemaErrorItem[] = useMemo( () => report?.errors_by_row?.flatMap((r) => r.errors) ?? [], [report] ); return (
Schema mismatch

{message ?? "Upload does not match the Redrob candidate schema"}

{report && (

{report.n_invalid} / {report.n_total} records invalid · first bad row #{ report.first_invalid_index ?? "?" }

)}
{tab === "errors" && } {tab === "fields" && diff && } {tab === "raw" && diff && ( )} {tab === "fields" && !diff && (

No diff available for this row.

)}
); }; // ───────────────────────── Errors view ───────────────────────────────────── const codeBadge: Record = { missing_required: "bg-red-500/30 text-red-100 border-red-400/40", wrong_type: "bg-amber-500/25 text-amber-100 border-amber-400/40", enum_violation: "bg-fuchsia-500/25 text-fuchsia-100 border-fuchsia-400/40", out_of_range: "bg-orange-500/25 text-orange-100 border-orange-400/40", pattern_mismatch: "bg-rose-500/25 text-rose-100 border-rose-400/40", bad_date: "bg-yellow-500/25 text-yellow-100 border-yellow-400/40", unknown_property: "bg-emerald-500/25 text-emerald-100 border-emerald-400/40", too_few_items: "bg-sky-500/25 text-sky-100 border-sky-400/40", too_many_items: "bg-sky-500/25 text-sky-100 border-sky-400/40", }; const ErrorList: React.FC<{ errors: SchemaErrorItem[] }> = ({ errors }) => { const scroll = useScrollIsolation(); if (!errors.length) return

No schema errors. ✓

; return (
    {errors.map((e, i) => (
  • {e.path} {e.code.replace(/_/g, " ")}

    {e.message}

    {(e.expected || e.actual) && (
    {e.expected && (
    expected
    {e.expected}
    )} {e.actual && (
    actual
    {e.actual}
    )}
    )}
  • ))}
); }; // ───────────────────────── Per-field diff view ───────────────────────────── const FieldDiff: React.FC<{ lines: DiffLine[] }> = ({ lines }) => { // Show missing + extra first, then matches. const sorted = useMemo( () => [...lines].sort((a, b) => { const order = { missing: 0, extra: 1, wrong: 2, match: 3 }; return order[a.kind] - order[b.kind]; }), [lines] ); const scroll = useScrollIsolation(); return (
{sorted.map((line, i) => ( ))}
); }; const DiffRow: React.FC<{ line: DiffLine }> = ({ line }) => { if (line.kind === "missing") { return (
- {line.path}: {render(line.expected)}{" "} // required by schema
); } if (line.kind === "extra") { return (
+ {line.path}: {render(line.actual)}{" "} // extra / undocumented
); } return (
{line.path}: {render(line.actual)}
); }; function render(value: unknown): string { if (value === null || value === undefined) return "null"; if (typeof value === "string") return JSON.stringify(value); if (typeof value === "number" || typeof value === "boolean") return String(value); try { const s = JSON.stringify(value); return s.length > 80 ? s.slice(0, 79) + "…" : s; } catch { return String(value); } } // ───────────────────────── Raw side-by-side diff ─────────────────────────── const RawDiff: React.FC<{ expected: unknown; actual: unknown }> = ({ expected, actual, }) => { const parts = useMemo(() => diffJson(expected ?? {}, actual ?? {}), [expected, actual]); const scroll = useScrollIsolation(); return (
      {parts.map((part, i) => {
        const cls = part.added
          ? "bg-emerald-500/10 text-emerald-100"
          : part.removed
          ? "bg-red-500/10 text-red-100"
          : "text-bone-300/80";
        const prefix = part.added ? "+ " : part.removed ? "- " : "  ";
        return (
          
            {part.value
              .split("\n")
              .filter((_, idx, arr) => !(idx === arr.length - 1 && _ === ""))
              .map((ln, j) => (
                
                  {prefix}
                  {ln}
                  {"\n"}
                
              ))}
          
        );
      })}
    
); }; export default SchemaDiff;