feat(expr): ExpressionField with token highlight overlay and live preview
This commit is contained in:
120
frontend/src/expr/ExpressionField.test.tsx
Normal file
120
frontend/src/expr/ExpressionField.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { useRef, useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ExpressionField, type ExpressionFieldHandle } from "./ExpressionField";
|
||||
|
||||
describe("ExpressionField", () => {
|
||||
it("emits the raw text on change", async () => {
|
||||
const onChange = vi.fn();
|
||||
function Harness() {
|
||||
const [v, setV] = useState("");
|
||||
return (
|
||||
<ExpressionField
|
||||
value={v}
|
||||
onChange={(next) => {
|
||||
onChange(next);
|
||||
setV(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
await userEvent.type(screen.getByRole("textbox"), "hi");
|
||||
expect(onChange).toHaveBeenLastCalledWith("hi");
|
||||
});
|
||||
|
||||
it("shows the live preview against previewInput", () => {
|
||||
render(
|
||||
<ExpressionField
|
||||
value="hi {input.name}!"
|
||||
onChange={() => {}}
|
||||
previewInput={{ name: "alice" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("expr-preview")).toHaveTextContent("hi alice!");
|
||||
});
|
||||
|
||||
it("omits the preview line when previewInput is undefined", () => {
|
||||
render(<ExpressionField value="hi {input.name}!" onChange={() => {}} />);
|
||||
expect(screen.queryByTestId("expr-preview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a schema-validity warning for a bad integer literal", () => {
|
||||
render(
|
||||
<ExpressionField
|
||||
value="abc"
|
||||
onChange={() => {}}
|
||||
schemaType="integer"
|
||||
previewInput={{}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("expr-preview")).toHaveTextContent(/expected integer/i);
|
||||
});
|
||||
|
||||
it("does not warn on integer literals that are valid", () => {
|
||||
render(
|
||||
<ExpressionField
|
||||
value="200"
|
||||
onChange={() => {}}
|
||||
schemaType="integer"
|
||||
previewInput={{}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("expr-preview")).toHaveTextContent("200");
|
||||
});
|
||||
|
||||
it("does not warn on integer fields when the value contains a template", () => {
|
||||
render(
|
||||
<ExpressionField
|
||||
value="{input.code}"
|
||||
onChange={() => {}}
|
||||
schemaType="integer"
|
||||
previewInput={{ code: 200 }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("expr-preview")).toHaveTextContent("200");
|
||||
});
|
||||
|
||||
it("insertAtCaret splices text at the current caret position", async () => {
|
||||
function Harness() {
|
||||
const ref = useRef<ExpressionFieldHandle>(null);
|
||||
const [v, setV] = useState("hello ");
|
||||
return (
|
||||
<>
|
||||
<ExpressionField ref={ref} value={v} onChange={setV} />
|
||||
<button onClick={() => ref.current?.insertAtCaret("{input.name}")}>insert</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const ta = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
ta.focus();
|
||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
await userEvent.click(screen.getByRole("button", { name: /insert/i }));
|
||||
expect(ta.value).toBe("hello {input.name}");
|
||||
});
|
||||
|
||||
it("single-line variant blurs on Enter instead of inserting a newline", async () => {
|
||||
const onChange = vi.fn();
|
||||
function Harness() {
|
||||
const [v, setV] = useState("");
|
||||
return (
|
||||
<ExpressionField
|
||||
value={v}
|
||||
onChange={(next) => {
|
||||
onChange(next);
|
||||
setV(next);
|
||||
}}
|
||||
singleLine
|
||||
/>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const ta = screen.getByRole("textbox");
|
||||
await userEvent.type(ta, "hi{Enter}");
|
||||
// The Enter keydown was captured — no newline reached the value.
|
||||
expect(onChange).toHaveBeenLastCalledWith("hi");
|
||||
expect(document.activeElement).not.toBe(ta);
|
||||
});
|
||||
});
|
||||
179
frontend/src/expr/ExpressionField.tsx
Normal file
179
frontend/src/expr/ExpressionField.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
import { render as renderTemplate } from "./templateEngine";
|
||||
|
||||
export interface ExpressionFieldHandle {
|
||||
insertAtCaret(text: string): void;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
export type ExpressionSchemaType = "string" | "integer" | "number" | "boolean";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onFocus?: () => void;
|
||||
previewInput?: unknown;
|
||||
schemaType?: ExpressionSchemaType;
|
||||
singleLine?: boolean;
|
||||
codeBlock?: boolean; // format:"code" fields — 10-row textarea, no Enter-blur
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const ExpressionField = forwardRef<ExpressionFieldHandle, Props>(function ExpressionField(
|
||||
{ value, onChange, onFocus, previewInput, schemaType = "string", singleLine, codeBlock, placeholder, id },
|
||||
ref,
|
||||
) {
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const [overlayScroll, setOverlayScroll] = useState({ top: 0, left: 0 });
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus() {
|
||||
taRef.current?.focus();
|
||||
},
|
||||
insertAtCaret(text: string) {
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart ?? ta.value.length;
|
||||
const end = ta.selectionEnd ?? ta.value.length;
|
||||
const next = ta.value.slice(0, start) + text + ta.value.slice(end);
|
||||
onChange(next);
|
||||
// Restore caret position after React re-renders the value.
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
const caret = start + text.length;
|
||||
ta.setSelectionRange(caret, caret);
|
||||
});
|
||||
},
|
||||
}), [onChange]);
|
||||
|
||||
const rows = codeBlock ? 10 : singleLine ? 1 : 3;
|
||||
|
||||
const tokens = useMemo(() => tokenize(value), [value]);
|
||||
const preview = useMemo(() => {
|
||||
if (previewInput === undefined) return null;
|
||||
if (schemaType !== "string" && value !== "" && value.indexOf("{") < 0) {
|
||||
if (!isValidLiteral(value, schemaType)) {
|
||||
return { kind: "warn" as const, text: `⚠ expected ${schemaType}` };
|
||||
}
|
||||
}
|
||||
const rendered = renderTemplate(value, previewInput);
|
||||
const truncated = rendered.length > 200 ? rendered.slice(0, 200) + "…" : rendered;
|
||||
return { kind: "ok" as const, text: truncated };
|
||||
}, [value, previewInput, schemaType]);
|
||||
|
||||
function syncScroll() {
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
setOverlayScroll({ top: ta.scrollTop, left: ta.scrollLeft });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.scrollTop = overlayScroll.top;
|
||||
overlayRef.current.scrollLeft = overlayScroll.left;
|
||||
}
|
||||
}, [overlayScroll]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={overlayRef}
|
||||
aria-hidden
|
||||
className={
|
||||
"absolute inset-0 pointer-events-none whitespace-pre-wrap break-words font-mono text-sm " +
|
||||
"px-3 py-2 leading-5 text-slate-100 overflow-hidden"
|
||||
}
|
||||
>
|
||||
{tokens.map((t, i) =>
|
||||
t.kind === "expr"
|
||||
? <span key={i} className="text-amber-300">{t.text}</span>
|
||||
: <span key={i}>{t.text}</span>,
|
||||
)}
|
||||
{/* Trailing newline safeguard: keeps overlay height equal to the textarea when
|
||||
the value ends in a newline. */}
|
||||
{value.endsWith("\n") && <span> </span>}
|
||||
</div>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
onScroll={syncScroll}
|
||||
onKeyDown={(e) => {
|
||||
if (singleLine && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLTextAreaElement).blur();
|
||||
}
|
||||
}}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
className={
|
||||
"relative w-full rounded bg-slate-950 border border-slate-800 " +
|
||||
"px-3 py-2 outline-none focus:border-brand-500 " +
|
||||
"font-mono text-sm leading-5 resize-none " +
|
||||
"text-transparent caret-slate-100 selection:text-slate-100 selection:bg-brand-600/40"
|
||||
}
|
||||
style={{ WebkitTextFillColor: "transparent" }}
|
||||
/>
|
||||
</div>
|
||||
{preview && (
|
||||
<div
|
||||
data-testid="expr-preview"
|
||||
className={
|
||||
"mt-1 text-xs font-mono truncate " +
|
||||
(preview.kind === "warn" ? "text-amber-400" : "text-slate-500")
|
||||
}
|
||||
>
|
||||
{preview.kind === "warn" ? preview.text : `→ ${preview.text}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type Token = { kind: "text" | "expr"; text: string };
|
||||
|
||||
// Tokenizer for the overlay. Matches the executor's parsing shape: `{{` and
|
||||
// `}}` are literal braces (still rendered as `{` / `}` in the "text" class),
|
||||
// unclosed `{` is rendered as text, and `{...}` runs are the "expr" class.
|
||||
function tokenize(s: string): Token[] {
|
||||
const out: Token[] = [];
|
||||
let i = 0;
|
||||
let buf = "";
|
||||
const flush = () => { if (buf) { out.push({ kind: "text", text: buf }); buf = ""; } };
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === "{") {
|
||||
if (s[i + 1] === "{") { buf += "{"; i += 2; continue; }
|
||||
const end = s.indexOf("}", i + 1);
|
||||
if (end < 0) { buf += s.slice(i); break; }
|
||||
flush();
|
||||
out.push({ kind: "expr", text: s.slice(i, end + 1) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (c === "}" && s[i + 1] === "}") { buf += "}"; i += 2; continue; }
|
||||
buf += c;
|
||||
i++;
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
function isValidLiteral(v: string, t: ExpressionSchemaType): boolean {
|
||||
if (t === "integer") {
|
||||
if (!/^-?\d+$/.test(v)) return false;
|
||||
return Number.isInteger(Number(v));
|
||||
}
|
||||
if (t === "number") {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n);
|
||||
}
|
||||
if (t === "boolean") return v === "true" || v === "false";
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user