williyam's picture
deploy: sync from talentry-ai @ b4f40d5
1c55403
Raw
History Blame Contribute Delete
10.1 kB
/**
* 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<Props> = ({ 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 (
<section className="card border border-red-500/40 bg-red-950/20 backdrop-blur p-5 sm:p-6 rounded-md">
<header className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<div className="text-[10px] uppercase tracking-widest text-red-300">
Schema mismatch
</div>
<h3 className="text-base sm:text-lg font-semibold text-red-100 mt-1">
{message ?? "Upload does not match the Redrob candidate schema"}
</h3>
{report && (
<p className="mt-1 text-xs font-mono text-red-200/80">
{report.n_invalid} / {report.n_total} records invalid · first bad row #{
report.first_invalid_index ?? "?"
}
</p>
)}
</div>
<nav className="flex text-[11px] uppercase tracking-widest divide-x divide-red-500/30 border border-red-500/30 rounded">
{(["errors", "fields", "raw"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-1.5 transition-colors ${
tab === t
? "bg-red-500/30 text-red-50"
: "text-red-200 hover:bg-red-500/10"
}`}
>
{t}
</button>
))}
</nav>
</header>
{tab === "errors" && <ErrorList errors={allErrors} />}
{tab === "fields" && diff && <FieldDiff lines={diff.lines} />}
{tab === "raw" && diff && (
<RawDiff expected={diff.expected_skeleton} actual={diff.actual} />
)}
{tab === "fields" && !diff && (
<p className="text-xs text-red-200">No diff available for this row.</p>
)}
</section>
);
};
// ───────────────────────── Errors view ─────────────────────────────────────
const codeBadge: Record<SchemaErrorItem["code"], string> = {
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<HTMLUListElement>();
if (!errors.length)
return <p className="text-sm text-emerald-300">No schema errors. ✓</p>;
return (
<ul
ref={scroll.ref}
onWheel={scroll.onWheel}
className="space-y-2 max-h-[50vh] overflow-auto custom-scroll pr-1"
style={{ overscrollBehavior: "auto" }}
data-lenis-prevent
>
{errors.map((e, i) => (
<li
key={i}
className="border border-red-500/20 bg-ink-900/50 rounded px-3 py-2 text-xs font-mono"
>
<div className="flex items-center justify-between gap-3">
<code className="text-red-100 truncate">{e.path}</code>
<span
className={`pill border ${codeBadge[e.code] ?? "border-red-400/40 text-red-100"}`}
>
{e.code.replace(/_/g, " ")}
</span>
</div>
<p className="mt-1 text-red-200/90 leading-relaxed">{e.message}</p>
{(e.expected || e.actual) && (
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
{e.expected && (
<div className="bg-emerald-500/10 border-l-2 border-emerald-400 px-2 py-1">
<div className="text-[9px] uppercase tracking-widest text-emerald-300">
expected
</div>
<code className="text-emerald-100 break-all">{e.expected}</code>
</div>
)}
{e.actual && (
<div className="bg-red-500/10 border-l-2 border-red-400 px-2 py-1">
<div className="text-[9px] uppercase tracking-widest text-red-300">
actual
</div>
<code className="text-red-100 break-all">{e.actual}</code>
</div>
)}
</div>
)}
</li>
))}
</ul>
);
};
// ───────────────────────── 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<HTMLDivElement>();
return (
<div
ref={scroll.ref}
onWheel={scroll.onWheel}
className="max-h-[55vh] overflow-auto custom-scroll border border-red-500/20 rounded bg-ink-950/70 font-mono text-[12px] leading-relaxed"
style={{ overscrollBehavior: "auto" }}
data-lenis-prevent
>
{sorted.map((line, i) => (
<DiffRow key={i} line={line} />
))}
</div>
);
};
const DiffRow: React.FC<{ line: DiffLine }> = ({ line }) => {
if (line.kind === "missing") {
return (
<div className="flex bg-red-500/10 border-l-2 border-red-500/70">
<span className="w-6 text-center text-red-300 select-none">-</span>
<code className="flex-1 px-2 py-0.5 text-red-100">
{line.path}: <span className="text-red-300/80">{render(line.expected)}</span>{" "}
<span className="text-red-300/60">// required by schema</span>
</code>
</div>
);
}
if (line.kind === "extra") {
return (
<div className="flex bg-emerald-500/10 border-l-2 border-emerald-400/70">
<span className="w-6 text-center text-emerald-300 select-none">+</span>
<code className="flex-1 px-2 py-0.5 text-emerald-100">
{line.path}: <span className="text-emerald-300/80">{render(line.actual)}</span>{" "}
<span className="text-emerald-300/60">// extra / undocumented</span>
</code>
</div>
);
}
return (
<div className="flex">
<span className="w-6 text-center text-bone-400/60 select-none"> </span>
<code className="flex-1 px-2 py-0.5 text-bone-300/80">
{line.path}: <span className="text-bone-400">{render(line.actual)}</span>
</code>
</div>
);
};
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<HTMLPreElement>();
return (
<pre
ref={scroll.ref}
onWheel={scroll.onWheel}
className="max-h-[55vh] overflow-auto custom-scroll text-[11.5px] leading-snug font-mono bg-ink-950/70 border border-red-500/20 rounded p-3"
style={{ overscrollBehavior: "auto" }}
data-lenis-prevent
>
{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 (
<span key={i} className={cls}>
{part.value
.split("\n")
.filter((_, idx, arr) => !(idx === arr.length - 1 && _ === ""))
.map((ln, j) => (
<span key={j}>
{prefix}
{ln}
{"\n"}
</span>
))}
</span>
);
})}
</pre>
);
};
export default SchemaDiff;