diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/JsonEditor.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/JsonEditor.test.tsx new file mode 100644 index 00000000000..c861c1d466d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/JsonEditor.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import JsonEditor from "./JsonEditor"; +import { validateSystemOnePayload } from "./lib/validatePayload"; + +const validPayload = JSON.stringify( + { state: "Hi", questions: { escalate: { type: "noul", instructions: "Escalate?" } } }, + null, + 2, +); + +describe("JsonEditor", () => { + it("marks a valid payload as ready to send and counts its lines", () => { + render(); + + expect(screen.getByText("Valid payload")).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Ready to send"); + expect(screen.getByText(`${validPayload.split("\n").length} lines`)).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "System One JSON payload" })).toHaveAttribute("aria-invalid", "false"); + }); + + it("lists each issue with its path and counts only errors in the status badge", () => { + const payload = JSON.stringify({ + state: "Hi", + questions: { + category: { type: "choice", instructions: 1, criteria: { support: "Help" } }, + urgency: { type: "score", instructions: "Rate", criteria: ["Low"] }, + }, + }); + render(); + + expect(screen.getByText("2 issues")).toBeInTheDocument(); + const issues = screen.getByRole("list", { name: "Payload validation issues" }); + expect(issues).toHaveTextContent("questions.category.instructionsInstructions must be a string."); + expect(issues).toHaveTextContent("questions.urgency.criteriaScore criteria must contain at least 2 levels."); + expect(screen.getByRole("textbox", { name: "System One JSON payload" })).toHaveAttribute("aria-invalid", "true"); + }); + + it("shows warnings without counting them as issues", () => { + const payload = JSON.stringify({ + state: "Hi", + questions: { urgency: { type: "score", instructions: "Rate", criteria: Array.from({ length: 11 }, () => "L") } }, + }); + render(); + + expect(screen.getByText("Valid payload")).toBeInTheDocument(); + expect(screen.getByRole("list", { name: "Payload validation issues" })).toHaveTextContent( + "More than 10 score levels may reduce result quality.", + ); + }); + + it("numbers every line, including a trailing empty one, so wrapped lines keep their number", () => { + const value = `${validPayload}\n`; + render(); + + const lineCount = value.split("\n").length; + expect(screen.getByText(`${lineCount} lines`)).toBeInTheDocument(); + expect(screen.getByText(String(lineCount))).toBeInTheDocument(); + expect(screen.queryByText(String(lineCount + 1))).not.toBeInTheDocument(); + }); + + it("reports edits to the caller", () => { + const onChange = vi.fn(); + render(); + + fireEvent.change(screen.getByRole("textbox", { name: "System One JSON payload" }), { target: { value: "{}" } }); + + expect(onChange).toHaveBeenCalledWith("{}"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/JsonEditor.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/JsonEditor.tsx new file mode 100644 index 00000000000..1effe55ef41 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/JsonEditor.tsx @@ -0,0 +1,167 @@ +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/cva.config"; +import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react"; +import { useId, useMemo, useRef } from "react"; +import { createElement, PrismLight as SyntaxHighlighter } from "react-syntax-highlighter"; +import type { SyntaxHighlighterProps } from "react-syntax-highlighter"; +import json from "react-syntax-highlighter/dist/esm/languages/prism/json"; +import { findRootBlocks, ROOT_BLOCK_STYLES, type RootBlock } from "./lib/rootBlocks"; +import type { SystemOnePayloadValidation } from "./lib/validatePayload"; + +SyntaxHighlighter.registerLanguage("json", json); + +const EDITOR_TEXT = "m-0 whitespace-pre-wrap wrap-anywhere py-3 font-mono text-xs leading-5 [scrollbar-gutter:stable]"; +const GUTTER_WIDTH = "w-11"; +type LineRendererProps = Parameters>[0]; +const CONTENT_INSET = "pl-14 pr-3"; +const CODE_TAG_PROPS = { className: "language-json", style: { whiteSpace: "pre-wrap" } } as const; + +const TOKEN_COLORS = [ + "[&_.token.property]:text-sky-700 dark:[&_.token.property]:text-sky-300", + "[&_.token.string]:text-emerald-700 dark:[&_.token.string]:text-emerald-300", + "[&_.token.number]:text-amber-700 dark:[&_.token.number]:text-amber-300", + "[&_.token.boolean]:text-violet-700 dark:[&_.token.boolean]:text-violet-300", + "[&_.token.null]:text-violet-700 dark:[&_.token.null]:text-violet-300", + "[&_.token.punctuation]:text-muted-foreground [&_.token.operator]:text-muted-foreground", +].join(" "); + +interface JsonEditorProps { + value: string; + onChange: (value: string) => void; + validation: SystemOnePayloadValidation; +} + +function ValidationStatus({ validation }: { validation: SystemOnePayloadValidation }) { + const errorCount = validation.issues.filter((issue) => issue.severity === "error").length; + if (errorCount > 0) { + return ( + + {errorCount} {errorCount === 1 ? "issue" : "issues"} + + ); + } + return Valid payload; +} + +function IssueList({ id, validation }: { id: string; validation: SystemOnePayloadValidation }) { + if (validation.issues.length === 0) { + return ( +

+ + Ready to send +

+ ); + } + return ( +
    + {validation.issues.map((issue, index) => ( +
  • + {issue.severity === "error" ? ( + + ) : ( + + )} + {issue.path} + {issue.message} +
  • + ))} +
+ ); +} + +function renderLines(rootBlocks: readonly RootBlock[]) { + return function LineRows({ rows, stylesheet, useInlineStyles }: LineRendererProps) { + return rows.map((row, line) => { + const lineElement = { node: row, stylesheet, useInlineStyles, key: line }; + const block = rootBlocks.find(({ startLine, endLine }) => line >= startLine && line <= endLine); + return ( +
+ + {line + 1} + + + {createElement(lineElement)} + +
+ ); + }); + }; +} + +export default function JsonEditor({ value, onChange, validation }: JsonEditorProps) { + const issuesId = useId(); + const highlightRef = useRef(null); + const renderer = useMemo(() => renderLines(findRootBlocks(value)), [value]); + const lineCount = value.split("\n").length; + const hasErrors = !validation.isValid; + + return ( +
+
+
+ Request JSON + +
+ + {lineCount} {lineCount === 1 ? "line" : "lines"} + +
+
+