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:
devin-ai-integration[bot] 2026-10-09 20:03:01 -07:00 • committed by GitHub
parent 63d823d28a
commit 62aee8bee9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 754 additions and 15 deletions

View file

@ -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">

View file

@ -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&apos;t show, such as a syntax error or a value that isn&apos;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>
);
}

View file

@ -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"]);
});
});
});

View file

@ -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

View file

@ -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" } });
});
});

View file

@ -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 };
};