mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
feat(ui): build System One playground requests with a form (#45721)
* feat(ui): build System One playground requests with a form * test(ui): fill System One form fields with single change events --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
63d823d28a
commit
62aee8bee9
6 changed files with 754 additions and 15 deletions
|
|
@ -31,7 +31,7 @@ interface JsonEditorProps {
|
|||
validation: SystemOnePayloadValidation;
|
||||
}
|
||||
|
||||
function ValidationStatus({ validation }: { validation: SystemOnePayloadValidation }) {
|
||||
export function ValidationStatus({ validation }: { validation: SystemOnePayloadValidation }) {
|
||||
const errorCount = validation.issues.filter((issue) => issue.severity === "error").length;
|
||||
if (errorCount > 0) {
|
||||
return (
|
||||
|
|
@ -43,7 +43,7 @@ function ValidationStatus({ validation }: { validation: SystemOnePayloadValidati
|
|||
return <Badge variant="secondary">Valid payload</Badge>;
|
||||
}
|
||||
|
||||
function IssueList({ id, validation }: { id: string; validation: SystemOnePayloadValidation }) {
|
||||
export function IssueList({ id, validation }: { id: string; validation: SystemOnePayloadValidation }) {
|
||||
if (validation.issues.length === 0) {
|
||||
return (
|
||||
<p id={id} role="status" className="flex items-center gap-1.5 border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,337 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useId, useMemo, useState, type ReactNode } from "react";
|
||||
import { IssueList, ValidationStatus } from "./JsonEditor";
|
||||
import {
|
||||
blankQuestion,
|
||||
freeName,
|
||||
readForm,
|
||||
removeAt,
|
||||
renameKey,
|
||||
replaceAt,
|
||||
retype,
|
||||
withNoulCriterion,
|
||||
withoutKey,
|
||||
type ChoiceQuestion,
|
||||
type FormPayload,
|
||||
type FormQuestion,
|
||||
type NoulQuestion,
|
||||
type QuestionType,
|
||||
type ScoreQuestion,
|
||||
} from "./lib/formPayload";
|
||||
import type { SystemOnePayloadValidation } from "./lib/validatePayload";
|
||||
|
||||
const QUESTION_TYPES: readonly QuestionType[] = ["choice", "noul", "score"];
|
||||
const TYPE_LABELS: Record<QuestionType, string> = { choice: "Choice", noul: "Yes / no", score: "Score" };
|
||||
|
||||
const isQuestionType = (value: unknown): value is QuestionType => QUESTION_TYPES.some((type) => type === value);
|
||||
|
||||
interface SystemOneFormProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
validation: SystemOnePayloadValidation;
|
||||
onOpenJson: () => void;
|
||||
}
|
||||
|
||||
interface QuestionEditorProps<Q extends FormQuestion> {
|
||||
question: Q;
|
||||
onChange: (question: FormQuestion) => void;
|
||||
}
|
||||
|
||||
interface KeyInputProps {
|
||||
value: string;
|
||||
taken: readonly string[];
|
||||
onCommit: (value: string) => void;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function keyError(next: string, current: string, taken: readonly string[]): string | undefined {
|
||||
if (next === "") {
|
||||
return "Enter a name";
|
||||
}
|
||||
if (next !== current && taken.includes(next)) {
|
||||
return `"${next}" is already used`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function KeyInput({ value, taken, onCommit, label, placeholder }: KeyInputProps) {
|
||||
const errorId = useId();
|
||||
const [draft, setDraft] = useState(value);
|
||||
const next = draft.trim();
|
||||
const error = keyError(next, value, taken);
|
||||
|
||||
function commit() {
|
||||
if (error === undefined && next !== value) {
|
||||
onCommit(next);
|
||||
return;
|
||||
}
|
||||
setDraft(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 flex-1 gap-1">
|
||||
<Input
|
||||
aria-label={label}
|
||||
aria-invalid={error !== undefined}
|
||||
aria-describedby={error === undefined ? undefined : errorId}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={commit}
|
||||
/>
|
||||
{error !== undefined && (
|
||||
<p id={errorId} className="text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" aria-label={label} title={label} onClick={onClick}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AddButton({ children, onClick }: { children: ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={onClick}>
|
||||
<Plus />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceOptions({ question, onChange }: QuestionEditorProps<ChoiceQuestion>) {
|
||||
const labels = Object.keys(question.criteria);
|
||||
const writeCriteria = (criteria: Record<string, string>) => onChange({ ...question, criteria });
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<span className="text-sm font-medium">Options</span>
|
||||
{Object.entries(question.criteria).map(([label, description], index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<KeyInput
|
||||
key={label}
|
||||
label="Option label"
|
||||
value={label}
|
||||
taken={labels}
|
||||
onCommit={(next) => writeCriteria(renameKey(question.criteria, label, next))}
|
||||
/>
|
||||
<Input
|
||||
aria-label={`Description of ${label}`}
|
||||
className="flex-2"
|
||||
value={description}
|
||||
placeholder="Optional description"
|
||||
onChange={(event) => writeCriteria({ ...question.criteria, [label]: event.target.value })}
|
||||
/>
|
||||
<RemoveButton
|
||||
label={`Remove option ${label}`}
|
||||
onClick={() => writeCriteria(withoutKey(question.criteria, label))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<AddButton onClick={() => writeCriteria({ ...question.criteria, [freeName(labels, "option")]: "" })}>
|
||||
Add option
|
||||
</AddButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoulCriteria({ question, onChange }: QuestionEditorProps<NoulQuestion>) {
|
||||
const yesId = useId();
|
||||
const noId = useId();
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={yesId}>Yes means</Label>
|
||||
<Input
|
||||
id={yesId}
|
||||
value={question.criteria?.true ?? ""}
|
||||
placeholder="Optional"
|
||||
onChange={(event) => onChange(withNoulCriterion(question, "true", event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={noId}>No means</Label>
|
||||
<Input
|
||||
id={noId}
|
||||
value={question.criteria?.false ?? ""}
|
||||
placeholder="Optional"
|
||||
onChange={(event) => onChange(withNoulCriterion(question, "false", event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreLevels({ question, onChange }: QuestionEditorProps<ScoreQuestion>) {
|
||||
const writeCriteria = (criteria: string[]) => onChange({ ...question, criteria });
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<span className="text-sm font-medium">Levels, lowest first</span>
|
||||
{question.criteria.map((level, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span className="w-6 shrink-0 text-right text-xs text-muted-foreground tabular-nums">{index}</span>
|
||||
<Input
|
||||
aria-label={`Level ${index}`}
|
||||
value={level}
|
||||
placeholder="What this score means"
|
||||
onChange={(event) => writeCriteria(replaceAt(question.criteria, index, event.target.value))}
|
||||
/>
|
||||
<RemoveButton
|
||||
label={`Remove level ${index}`}
|
||||
onClick={() => writeCriteria(removeAt(question.criteria, index))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<AddButton onClick={() => writeCriteria([...question.criteria, ""])}>Add level</AddButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CriteriaEditor({ question, onChange }: QuestionEditorProps<FormQuestion>) {
|
||||
switch (question.type) {
|
||||
case "choice":
|
||||
return <ChoiceOptions question={question} onChange={onChange} />;
|
||||
case "noul":
|
||||
return <NoulCriteria question={question} onChange={onChange} />;
|
||||
case "score":
|
||||
return <ScoreLevels question={question} onChange={onChange} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface QuestionCardProps extends QuestionEditorProps<FormQuestion> {
|
||||
name: string;
|
||||
taken: readonly string[];
|
||||
onRename: (name: string) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function QuestionCard({ name, question, taken, onChange, onRename, onRemove }: QuestionCardProps) {
|
||||
const instructionsId = useId();
|
||||
|
||||
return (
|
||||
<fieldset aria-label={`Question ${name}`} className="grid gap-3 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<KeyInput key={name} label="Question name" value={name} taken={taken} onCommit={onRename} />
|
||||
<Select
|
||||
value={question.type}
|
||||
onValueChange={(type) => isQuestionType(type) && onChange(retype(question, type))}
|
||||
>
|
||||
<SelectTrigger className="w-36" aria-label="Answer type">
|
||||
<SelectValue>{TYPE_LABELS[question.type]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{QUESTION_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<RemoveButton label={`Remove question ${name}`} onClick={onRemove} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={instructionsId}>Instructions</Label>
|
||||
<Textarea
|
||||
id={instructionsId}
|
||||
rows={2}
|
||||
value={question.instructions ?? ""}
|
||||
placeholder="What should the model decide?"
|
||||
onChange={(event) => onChange({ ...question, instructions: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<CriteriaEditor question={question} onChange={onChange} />
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function FormFrame({ validation, children }: { validation: SystemOnePayloadValidation; children: ReactNode }) {
|
||||
const issuesId = useId();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-96 flex-1 flex-col overflow-hidden rounded-md border bg-background",
|
||||
!validation.isValid && "border-destructive/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">Request form</span>
|
||||
<ValidationStatus validation={validation} />
|
||||
</div>
|
||||
<div className="grid min-h-80 flex-1 content-start gap-4 overflow-auto p-3">{children}</div>
|
||||
<IssueList id={issuesId} validation={validation} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemOneForm({ value, onChange, validation, onOpenJson }: SystemOneFormProps) {
|
||||
const stateId = useId();
|
||||
const form = useMemo(() => readForm(value), [value]);
|
||||
|
||||
if (form === undefined) {
|
||||
return (
|
||||
<FormFrame validation={validation}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This request has JSON the form can't show, such as a syntax error or a value that isn't text.
|
||||
</p>
|
||||
<Button variant="outline" className="w-fit" onClick={onOpenJson}>
|
||||
Edit in JSON
|
||||
</Button>
|
||||
</FormFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const questions = form.questions ?? {};
|
||||
const names = Object.keys(questions);
|
||||
const write = (next: FormPayload) => onChange(JSON.stringify(next, null, 2));
|
||||
const writeQuestions = (next: Record<string, FormQuestion>) => write({ ...form, questions: next });
|
||||
|
||||
return (
|
||||
<FormFrame validation={validation}>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={stateId}>Input</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The text the model reads before it answers, such as a support ticket or a chat message.
|
||||
</p>
|
||||
<Textarea
|
||||
id={stateId}
|
||||
rows={4}
|
||||
value={form.state ?? ""}
|
||||
placeholder="Paste the text the model should decide about"
|
||||
onChange={(event) => write({ ...form, state: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{Object.entries(questions).map(([name, question], index) => (
|
||||
<QuestionCard
|
||||
key={index}
|
||||
name={name}
|
||||
question={question}
|
||||
taken={names}
|
||||
onChange={(next) => writeQuestions({ ...questions, [name]: next })}
|
||||
onRename={(next) => writeQuestions(renameKey(questions, name, next))}
|
||||
onRemove={() => writeQuestions(withoutKey(questions, name))}
|
||||
/>
|
||||
))}
|
||||
<AddButton onClick={() => writeQuestions({ ...questions, [freeName(names, "question")]: blankQuestion() })}>
|
||||
Add question
|
||||
</AddButton>
|
||||
</FormFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
screen,
|
||||
waitFor,
|
||||
waitForElementToBeRemoved,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
|
@ -58,6 +59,11 @@ const isModelLookup = (input: RequestInfo | URL) => String(input).endsWith("/mod
|
|||
const lookupAuthHeaders = (calls: Parameters<typeof fetch>[]) =>
|
||||
calls.map(([, init]) => Object.values((init?.headers ?? {}) as Record<string, string>));
|
||||
|
||||
const openJsonEditor = async () => {
|
||||
await userEvent.click(screen.getByRole("tab", { name: "JSON" }));
|
||||
return screen.getByRole("textbox", { name: "System One JSON payload" });
|
||||
};
|
||||
|
||||
const createResponse = (body: SystemOneResponse, status = 200, errorText = "") =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
|
|
@ -89,7 +95,7 @@ describe("SystemOneUI integration", () => {
|
|||
it("restores the example after the request is edited", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
const editor = screen.getByRole("textbox", { name: "System One JSON payload" });
|
||||
const editor = await openJsonEditor();
|
||||
const example = (editor as HTMLTextAreaElement).value;
|
||||
const resetButton = screen.getByRole("button", { name: "Reset example" });
|
||||
expect(resetButton).toBeDisabled();
|
||||
|
|
@ -153,7 +159,7 @@ describe("SystemOneUI integration", () => {
|
|||
expect(screen.queryByRole("option", { name: "gpt-5.5" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("option", { name: "pplx-decider" }));
|
||||
|
||||
const editor = screen.getByRole("textbox", { name: "System One JSON payload" });
|
||||
const editor = await openJsonEditor();
|
||||
expect(JSON.parse((editor as HTMLTextAreaElement).value)).toMatchObject({
|
||||
model: "pplx-decider",
|
||||
questions: { has_repro_steps: { type: "noul" } },
|
||||
|
|
@ -210,7 +216,7 @@ describe("SystemOneUI integration", () => {
|
|||
const models = Promise.withResolvers<Response>();
|
||||
mockModelLookup.mockReturnValue(models.promise);
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
const editor = screen.getByRole("textbox", { name: "System One JSON payload" });
|
||||
const editor = await openJsonEditor();
|
||||
const draft = JSON.stringify({ state: "edited", questions: { q: { type: "noul", instructions: "Yes?" } } });
|
||||
fireEvent.change(editor, { target: { value: draft } });
|
||||
|
||||
|
|
@ -226,7 +232,7 @@ describe("SystemOneUI integration", () => {
|
|||
it("shows invalid JSON and disables Send", async () => {
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "System One JSON payload" }), {
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
target: { value: "{" },
|
||||
});
|
||||
|
||||
|
|
@ -273,7 +279,7 @@ describe("SystemOneUI integration", () => {
|
|||
it("keeps legacy validation when question values have invalid types", async () => {
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "System One JSON payload" }), {
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
target: {
|
||||
value: JSON.stringify({
|
||||
state: "A new support request",
|
||||
|
|
@ -299,7 +305,7 @@ describe("SystemOneUI integration", () => {
|
|||
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: "System One · /v1/systemone" }));
|
||||
const editor = screen.getByRole("textbox", { name: "System One JSON payload" });
|
||||
const editor = await openJsonEditor();
|
||||
const draft = JSON.stringify({
|
||||
model: "my-decider",
|
||||
state: {},
|
||||
|
|
@ -337,7 +343,7 @@ describe("SystemOneUI integration", () => {
|
|||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: "System One · /v1/systemone" }));
|
||||
const payload = { state: "An outage", questions: { urgent: { type: "noul", instructions: "Is this urgent?" } } };
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "System One JSON payload" }), {
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
target: { value: JSON.stringify(payload) },
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||
|
|
@ -442,7 +448,7 @@ describe("SystemOneUI integration", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "System One JSON payload" }), {
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
target: {
|
||||
value: JSON.stringify({
|
||||
state: "A different support request",
|
||||
|
|
@ -500,4 +506,160 @@ describe("SystemOneUI integration", () => {
|
|||
expect(mockFetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
|
||||
});
|
||||
|
||||
describe("request form", () => {
|
||||
const question = (name: string) => within(screen.getByRole("group", { name: `Question ${name}` }));
|
||||
const sentBody = () => JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string);
|
||||
|
||||
async function pickAnswerType(user: ReturnType<typeof userEvent.setup>, name: string, type: string) {
|
||||
question(name).getByRole("combobox", { name: "Answer type" }).focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("option", { name: type }));
|
||||
}
|
||||
|
||||
const fill = (field: HTMLElement, value: string) => fireEvent.change(field, { target: { value } });
|
||||
|
||||
function rename(field: HTMLElement, name: string) {
|
||||
fill(field, name);
|
||||
fireEvent.blur(field);
|
||||
}
|
||||
|
||||
it("sends a choice and a yes/no question built in the form without opening JSON", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
const input = screen.getByRole("textbox", { name: "Input" });
|
||||
fill(input, "The login page is blank after the upgrade");
|
||||
for (const name of ["area", "has_repro_steps", "severity"]) {
|
||||
await user.click(screen.getByRole("button", { name: `Remove question ${name}` }));
|
||||
}
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add question" }));
|
||||
rename(question("question_1").getByRole("textbox", { name: "Question name" }), "team");
|
||||
fill(question("team").getByRole("textbox", { name: "Instructions" }), "Which team owns this?");
|
||||
const [first, second] = question("team").getAllByRole("textbox", { name: "Option label" });
|
||||
rename(first, "frontend");
|
||||
rename(second, "backend");
|
||||
fill(question("team").getByRole("textbox", { name: "Description of frontend" }), "Dashboard and UI");
|
||||
await user.click(question("team").getByRole("button", { name: "Add option" }));
|
||||
rename(question("team").getAllByRole("textbox", { name: "Option label" })[2], "infra");
|
||||
fill(question("team").getByRole("textbox", { name: "Description of infra" }), "Deploys");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add question" }));
|
||||
await pickAnswerType(user, "question_1", "Yes / no");
|
||||
rename(question("question_1").getByRole("textbox", { name: "Question name" }), "urgent");
|
||||
fill(question("urgent").getByRole("textbox", { name: "Instructions" }), "Is this blocking users?");
|
||||
fill(question("urgent").getByRole("textbox", { name: "Yes means" }), "Nobody can log in");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: "System One JSON payload" })).not.toBeInTheDocument();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/typesafe\/v1\/systemone$/);
|
||||
expect(sentBody()).toEqual({
|
||||
model: "jev-latest",
|
||||
state: "The login page is blank after the upgrade",
|
||||
questions: {
|
||||
team: {
|
||||
type: "choice",
|
||||
instructions: "Which team owns this?",
|
||||
criteria: { frontend: "Dashboard and UI", backend: "", infra: "Deploys" },
|
||||
},
|
||||
urgent: { type: "noul", instructions: "Is this blocking users?", criteria: { true: "Nobody can log in" } },
|
||||
},
|
||||
});
|
||||
expect(Object.keys(sentBody().questions)).toEqual(["team", "urgent"]);
|
||||
expect(Object.keys(sentBody().questions.team.criteria)).toEqual(["frontend", "backend", "infra"]);
|
||||
});
|
||||
|
||||
it("edits score levels and keeps instructions when a question changes type", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
expect(await screen.findByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest");
|
||||
|
||||
const level = question("severity").getByRole("textbox", { name: "Level 0" });
|
||||
fill(level, "Typo");
|
||||
await user.click(question("severity").getByRole("button", { name: "Remove level 4" }));
|
||||
await user.click(question("severity").getByRole("button", { name: "Add level" }));
|
||||
fill(question("severity").getByRole("textbox", { name: "Level 4" }), "Data loss");
|
||||
await pickAnswerType(user, "has_repro_steps", "Choice");
|
||||
await user.click(question("has_repro_steps").getByRole("button", { name: "Add option" }));
|
||||
await user.click(question("area").getByRole("button", { name: "Remove option docs" }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/v1\/systemone$/);
|
||||
expect(sentBody().model).toBe("jev-latest");
|
||||
expect(sentBody().questions.severity.criteria).toEqual([
|
||||
"Typo",
|
||||
"Minor bug with an easy workaround",
|
||||
"Broken feature with a workaround",
|
||||
"Broken feature with no workaround",
|
||||
"Data loss",
|
||||
]);
|
||||
expect(sentBody().questions.has_repro_steps).toEqual({
|
||||
type: "choice",
|
||||
instructions: "Does the issue include steps someone could follow to reproduce it?",
|
||||
criteria: { option_1: "", option_2: "", option_3: "" },
|
||||
});
|
||||
expect(Object.keys(sentBody().questions.area.criteria)).toEqual(["backend", "sdk", "ui"]);
|
||||
});
|
||||
|
||||
it("refuses an empty or taken question name and trims a new one", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
const name = question("severity").getByRole("textbox", { name: "Question name" });
|
||||
expect(name).toHaveAttribute("aria-invalid", "false");
|
||||
|
||||
fill(name, "area");
|
||||
expect(name).toHaveAttribute("aria-invalid", "true");
|
||||
expect(question("severity").getByText('"area" is already used')).toBeInTheDocument();
|
||||
fireEvent.blur(name);
|
||||
expect(name).toHaveValue("severity");
|
||||
|
||||
fill(name, "");
|
||||
expect(question("severity").getByText("Enter a name")).toBeInTheDocument();
|
||||
fireEvent.blur(name);
|
||||
expect(name).toHaveValue("severity");
|
||||
|
||||
rename(name, " impact ");
|
||||
expect(question("impact").getByRole("textbox", { name: "Question name" })).toHaveValue("impact");
|
||||
await user.click(screen.getByRole("button", { name: "Add question" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add question" }));
|
||||
const json = JSON.parse(((await openJsonEditor()) as HTMLTextAreaElement).value);
|
||||
expect(Object.keys(json.questions)).toEqual(["area", "has_repro_steps", "impact", "question_1", "question_2"]);
|
||||
});
|
||||
|
||||
it("round-trips unknown fields between JSON and the form and points to JSON when it cannot show one", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SystemOneUI accessToken="session-key" />);
|
||||
expect(screen.queryByRole("button", { name: "Format JSON" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(await openJsonEditor(), {
|
||||
target: { value: JSON.stringify({ state: { ticket: 1 }, questions: {} }) },
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Format JSON" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(screen.getByText(/This request has JSON the form can.t show/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Edit in JSON" }));
|
||||
|
||||
const payload = {
|
||||
model: "m",
|
||||
state: "s",
|
||||
metadata: { k: 1 },
|
||||
questions: { q: { type: "noul", instructions: "i", note: "x" } },
|
||||
};
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "System One JSON payload" }), {
|
||||
target: { value: JSON.stringify(payload) },
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: "Form" }));
|
||||
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue("s");
|
||||
fill(question("q").getByRole("textbox", { name: "Yes means" }), "y");
|
||||
|
||||
const json = JSON.parse(((await openJsonEditor()) as HTMLTextAreaElement).value);
|
||||
expect(json).toEqual({ ...payload, questions: { q: { ...payload.questions.q, criteria: { true: "y" } } } });
|
||||
expect(Object.keys(json)).toEqual(["model", "state", "metadata", "questions"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { DECISIONS_DOCS_URL } from "@/lib/decisionModels";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
|
|
@ -17,6 +18,7 @@ import type { DecisionEndpoint, PlaygroundRequest } from "./lib/schemas";
|
|||
import JsonEditor from "./JsonEditor";
|
||||
import QuestionBreakdown from "./QuestionBreakdown";
|
||||
import ResponseView from "./ResponseView";
|
||||
import SystemOneForm from "./SystemOneForm";
|
||||
import { validateSystemOnePayload } from "./lib/validatePayload";
|
||||
import { useDecisionModels, type ApiKeySource } from "./useDecisionModels";
|
||||
|
||||
|
|
@ -25,6 +27,8 @@ interface SystemOneUIProps {
|
|||
disabledPersonalKeyCreation?: boolean;
|
||||
}
|
||||
|
||||
type EditorView = "form" | "json";
|
||||
|
||||
interface SystemOneSendVariables {
|
||||
payload: PlaygroundRequest;
|
||||
endpoint: DecisionEndpoint;
|
||||
|
|
@ -52,6 +56,7 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
const endpoint: DecisionEndpoint =
|
||||
chosenEndpoint ?? (decisionModels.length > 0 ? "/v1/systemone" : "/typesafe/v1/systemone");
|
||||
const [drafts, setDrafts] = useState<Partial<Record<DecisionEndpoint, string>>>({});
|
||||
const [view, setView] = useState<EditorView>("form");
|
||||
const examplePayload =
|
||||
endpoint === "/v1/systemone"
|
||||
? JSON.stringify(decisionsExample(decisionModels[0] ?? PLACEHOLDER_DECISION_MODEL), null, 2)
|
||||
|
|
@ -206,10 +211,12 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
<RotateCcw />
|
||||
Reset example
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleFormatJson} disabled={!rawPayload.trim() || hasSyntaxError}>
|
||||
<Code />
|
||||
Format JSON
|
||||
</Button>
|
||||
{view === "json" && (
|
||||
<Button variant="outline" onClick={handleFormatJson} disabled={!rawPayload.trim() || hasSyntaxError}>
|
||||
<Code />
|
||||
Format JSON
|
||||
</Button>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Button variant="outline" onClick={clearRequestState}>
|
||||
Cancel request
|
||||
|
|
@ -243,7 +250,27 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
|
|||
|
||||
<div className="grid gap-4 xl:min-h-0 xl:flex-1 xl:grid-cols-2">
|
||||
<section className="flex min-h-96 flex-col xl:min-h-0" aria-label="System One request editor">
|
||||
<JsonEditor value={rawPayload} onChange={handlePayloadChange} validation={validation} />
|
||||
<Tabs
|
||||
value={view}
|
||||
onValueChange={(value) => (value === "form" || value === "json") && setView(value)}
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<TabsList aria-label="Request editor view">
|
||||
<TabsTrigger value="form">Form</TabsTrigger>
|
||||
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="form" className="flex min-h-0 flex-col">
|
||||
<SystemOneForm
|
||||
value={rawPayload}
|
||||
onChange={handlePayloadChange}
|
||||
validation={validation}
|
||||
onOpenJson={() => setView("json")}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="json" className="flex min-h-0 flex-col">
|
||||
<JsonEditor value={rawPayload} onChange={handlePayloadChange} validation={validation} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
<section className="grid content-start gap-4 xl:min-h-0 xl:overflow-auto" aria-label="System One results">
|
||||
<ResponseView
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { SYSTEM_ONE_EXAMPLE } from "./example";
|
||||
import {
|
||||
blankQuestion,
|
||||
freeName,
|
||||
readForm,
|
||||
removeAt,
|
||||
renameKey,
|
||||
replaceAt,
|
||||
retype,
|
||||
withNoulCriterion,
|
||||
withoutKey,
|
||||
type NoulQuestion,
|
||||
} from "./formPayload";
|
||||
|
||||
describe("readForm", () => {
|
||||
it("reads the example request as it is", () => {
|
||||
expect(readForm(JSON.stringify(SYSTEM_ONE_EXAMPLE))).toEqual(SYSTEM_ONE_EXAMPLE);
|
||||
});
|
||||
|
||||
it("keeps fields the form does not edit, in the order the request had them", () => {
|
||||
const raw = JSON.stringify({
|
||||
metadata: { k: 1 },
|
||||
questions: { q: { note: "x", type: "noul", instructions: "i" } },
|
||||
state: "s",
|
||||
});
|
||||
const form = readForm(raw);
|
||||
expect(JSON.stringify(form)).toBe(raw);
|
||||
});
|
||||
|
||||
it("reads a request with no state or questions yet", () => {
|
||||
expect(readForm("{}")).toEqual({});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["broken JSON", "{"],
|
||||
["a non-object", "[1]"],
|
||||
["a non-text state", JSON.stringify({ state: { ticket: 1 } })],
|
||||
["an unknown answer type", JSON.stringify({ questions: { q: { type: "rank", criteria: [] } } })],
|
||||
["a non-text option description", JSON.stringify({ questions: { q: { type: "choice", criteria: { a: 1 } } } })],
|
||||
["a non-text score level", JSON.stringify({ questions: { q: { type: "score", criteria: [1, 2] } } })],
|
||||
["a non-text yes/no criterion", JSON.stringify({ questions: { q: { type: "noul", criteria: { true: 1 } } } })],
|
||||
["non-text instructions", JSON.stringify({ questions: { q: { type: "noul", instructions: { a: 1 } } } })],
|
||||
])("cannot show %s", (_, raw) => {
|
||||
expect(readForm(raw)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("freeName", () => {
|
||||
it("picks the first unused numbered name", () => {
|
||||
expect(freeName([], "question")).toBe("question_1");
|
||||
expect(freeName(["question_1", "question_3"], "question")).toBe("question_2");
|
||||
expect(freeName(["option_1", "option_2"], "option")).toBe("option_3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("record and list edits", () => {
|
||||
it("renames a key in place", () => {
|
||||
const renamed = renameKey({ a: 1, b: 2, c: 3 }, "b", "z");
|
||||
expect(Object.entries(renamed)).toEqual([
|
||||
["a", 1],
|
||||
["z", 2],
|
||||
["c", 3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes only the named key", () => {
|
||||
expect(withoutKey({ a: 1, b: 2 }, "a")).toEqual({ b: 2 });
|
||||
});
|
||||
|
||||
it("replaces and removes list items by position", () => {
|
||||
expect(replaceAt(["a", "b", "c"], 1, "x")).toEqual(["a", "x", "c"]);
|
||||
expect(removeAt(["a", "b", "c"], 1)).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retype", () => {
|
||||
const noul = { type: "noul", instructions: "Is it urgent?", note: "kept", criteria: { true: "Yes" } } as const;
|
||||
|
||||
it("keeps instructions and other fields and starts the new type with blank criteria", () => {
|
||||
const asChoice = {
|
||||
type: "choice",
|
||||
instructions: "Is it urgent?",
|
||||
note: "kept",
|
||||
criteria: { option_1: "", option_2: "" },
|
||||
};
|
||||
const asScore = { type: "score", instructions: "Is it urgent?", note: "kept", criteria: ["", ""] };
|
||||
expect(retype(noul, "choice")).toEqual(asChoice);
|
||||
expect(retype(noul, "score")).toEqual(asScore);
|
||||
expect(retype(blankQuestion(), "noul")).toEqual({ type: "noul", instructions: "" });
|
||||
});
|
||||
|
||||
it("keeps type as the first field so the JSON view reads the same as a new question", () => {
|
||||
expect(Object.keys(retype(noul, "choice"))).toEqual(["type", "instructions", "note", "criteria"]);
|
||||
expect(Object.keys(retype(noul, "score"))).toEqual(["type", "instructions", "note", "criteria"]);
|
||||
expect(Object.keys(retype(blankQuestion(), "noul"))).toEqual(["type", "instructions"]);
|
||||
});
|
||||
|
||||
it("leaves a question alone when the type does not change", () => {
|
||||
expect(retype(noul, "noul")).toBe(noul);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withNoulCriterion", () => {
|
||||
const question: NoulQuestion = { type: "noul", instructions: "i", criteria: { true: "Yes", false: "No" } };
|
||||
|
||||
it("sets one side and keeps the other", () => {
|
||||
expect(withNoulCriterion(question, "false", "Nope")).toEqual({
|
||||
type: "noul",
|
||||
instructions: "i",
|
||||
criteria: { true: "Yes", false: "Nope" },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a side left blank and drops the criteria when both are blank", () => {
|
||||
const yesOnly = withNoulCriterion(question, "false", "");
|
||||
expect(yesOnly).toEqual({ type: "noul", instructions: "i", criteria: { true: "Yes" } });
|
||||
expect(withNoulCriterion(yesOnly, "true", "")).toEqual({ type: "noul", instructions: "i" });
|
||||
});
|
||||
|
||||
it("adds criteria to a question that had none", () => {
|
||||
expect(withNoulCriterion({ type: "noul" }, "true", "Yes")).toEqual({ type: "noul", criteria: { true: "Yes" } });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { z } from "zod";
|
||||
import { parseJson } from "./validatePayload";
|
||||
|
||||
const choiceQuestion = z.looseObject({
|
||||
type: z.literal("choice"),
|
||||
instructions: z.string().optional(),
|
||||
criteria: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
const noulQuestion = z.looseObject({
|
||||
type: z.literal("noul"),
|
||||
instructions: z.string().optional(),
|
||||
criteria: z.looseObject({ true: z.string().optional(), false: z.string().optional() }).optional(),
|
||||
});
|
||||
|
||||
const scoreQuestion = z.looseObject({
|
||||
type: z.literal("score"),
|
||||
instructions: z.string().optional(),
|
||||
criteria: z.array(z.string()),
|
||||
});
|
||||
|
||||
const formQuestion = z.discriminatedUnion("type", [choiceQuestion, noulQuestion, scoreQuestion]);
|
||||
|
||||
const formPayload = z.looseObject({
|
||||
state: z.string().optional(),
|
||||
questions: z.record(z.string(), formQuestion).optional(),
|
||||
});
|
||||
|
||||
export type FormPayload = z.infer<typeof formPayload>;
|
||||
export type FormQuestion = z.infer<typeof formQuestion>;
|
||||
export type ChoiceQuestion = z.infer<typeof choiceQuestion>;
|
||||
export type NoulQuestion = z.infer<typeof noulQuestion>;
|
||||
export type ScoreQuestion = z.infer<typeof scoreQuestion>;
|
||||
export type QuestionType = FormQuestion["type"];
|
||||
export type NoulSide = "true" | "false";
|
||||
|
||||
const formPayloadInInputOrder = z.custom<FormPayload>((value) => formPayload.safeParse(value).success);
|
||||
|
||||
export const readForm = (raw: string): FormPayload | undefined => {
|
||||
const json = parseJson(raw);
|
||||
if (!json.ok) {
|
||||
return undefined;
|
||||
}
|
||||
return formPayloadInInputOrder.safeParse(json.value).data;
|
||||
};
|
||||
|
||||
export const freeName = (taken: readonly string[], prefix: string): string =>
|
||||
Array.from({ length: taken.length + 1 }, (_, index) => `${prefix}_${index + 1}`).find(
|
||||
(name) => !taken.includes(name),
|
||||
) ?? `${prefix}_${taken.length + 1}`;
|
||||
|
||||
export const renameKey = <T>(record: Readonly<Record<string, T>>, from: string, to: string): Record<string, T> =>
|
||||
Object.fromEntries(Object.entries(record).map(([key, value]) => [key === from ? to : key, value]));
|
||||
|
||||
export const withoutKey = <T>(record: Readonly<Record<string, T>>, key: string): Record<string, T> =>
|
||||
Object.fromEntries(Object.entries(record).filter(([entry]) => entry !== key));
|
||||
|
||||
export const replaceAt = <T>(items: readonly T[], index: number, value: T): T[] =>
|
||||
items.map((item, position) => (position === index ? value : item));
|
||||
|
||||
export const removeAt = <T>(items: readonly T[], index: number): T[] =>
|
||||
items.filter((_, position) => position !== index);
|
||||
|
||||
export const blankQuestion = (): ChoiceQuestion => ({
|
||||
type: "choice",
|
||||
instructions: "",
|
||||
criteria: { option_1: "", option_2: "" },
|
||||
});
|
||||
|
||||
export const retype = (question: FormQuestion, type: QuestionType): FormQuestion => {
|
||||
if (question.type === type) {
|
||||
return question;
|
||||
}
|
||||
const { criteria: _criteria, type: _type, ...rest } = question;
|
||||
switch (type) {
|
||||
case "choice":
|
||||
return { type, ...rest, criteria: blankQuestion().criteria };
|
||||
case "noul":
|
||||
return { type, ...rest };
|
||||
case "score":
|
||||
return { type, ...rest, criteria: ["", ""] };
|
||||
}
|
||||
};
|
||||
|
||||
export const withNoulCriterion = (question: NoulQuestion, side: NoulSide, text: string): NoulQuestion => {
|
||||
const { criteria: current, ...rest } = question;
|
||||
const criteria = Object.fromEntries(Object.entries({ ...current, [side]: text }).filter(([, value]) => value !== ""));
|
||||
return Object.keys(criteria).length === 0 ? rest : { ...rest, criteria };
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue