reuse widget and refactor for multiline

This commit is contained in:
Josh Hawkins 2026-03-26 12:05:00 -05:00
parent b1732a1767
commit 0bd2bdc1c1
4 changed files with 89 additions and 72 deletions

View File

@ -29,9 +29,10 @@ const review: SectionConfigOverrides = {
},
genai: {
additional_concerns: {
"ui:widget": "textareaArray",
"ui:widget": "ArrayAsTextWidget",
"ui:options": {
size: "full",
multiline: true,
},
},
activity_context_prompt: {

View File

@ -30,7 +30,6 @@ import { CameraPathWidget } from "./widgets/CameraPathWidget";
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
import { TextareaArrayWidget } from "./widgets/TextareaArrayWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
@ -82,7 +81,6 @@ export const frigateTheme: FrigateTheme = {
optionalField: OptionalFieldWidget,
semanticSearchModel: SemanticSearchModelWidget,
onvifProfile: OnvifProfileWidget,
textareaArray: TextareaArrayWidget,
},
templates: {
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,

View File

@ -1,33 +1,101 @@
// Widget that displays an array as a concatenated text string
// Widget that displays an array as editable text.
// Single-line mode (default): space-separated in an Input.
// Multiline mode (options.multiline): one item per line in a Textarea.
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { useCallback } from "react";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
import { useCallback, useEffect, useState } from "react";
function arrayToText(value: unknown, multiline: boolean): string {
const sep = multiline ? "\n" : " ";
if (Array.isArray(value) && value.length > 0) {
return value.join(sep);
}
if (typeof value === "string") {
return value;
}
return "";
}
function textToArray(text: string, multiline: boolean): string[] {
if (text.trim() === "") {
return [];
}
return multiline
? text.split("\n").filter((line) => line.trim() !== "")
: text.trim().split(/\s+/);
}
export function ArrayAsTextWidget(props: WidgetProps) {
const { value, onChange, disabled, readonly, placeholder } = props;
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
// Convert array or string to text
let textValue = "";
if (typeof value === "string" && value.length > 0) {
textValue = value;
} else if (Array.isArray(value) && value.length > 0) {
textValue = value.join(" ");
}
const multiline = !!(options.multiline as boolean);
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const newText = event.target.value;
// Convert space-separated string back to array
const newArray = newText.trim() ? newText.trim().split(/\s+/) : [];
onChange(newArray);
// Local state keeps raw text so newlines aren't stripped mid-typing
const [text, setText] = useState(() => arrayToText(value, multiline));
useEffect(() => {
setText(arrayToText(value, multiline));
}, [value, multiline]);
const fieldClassName = multiline
? getSizedFieldClassName(options, "md")
: undefined;
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const raw = e.target.value;
setText(raw);
onChange(textToArray(raw, multiline));
},
[onChange],
[onChange, multiline],
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// Clean up: strip empty entries and sync
const cleaned = textToArray(e.target.value, multiline);
onChange(cleaned);
setText(arrayToText(cleaned, multiline));
onBlur?.(id, e.target.value);
},
[id, onChange, onBlur, multiline],
);
if (multiline) {
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
value={text}
disabled={disabled || readonly}
rows={(options.rows as number) || 3}
onChange={handleInputChange}
onBlur={handleBlur}
onFocus={(e) => onFocus?.(id, e.target.value)}
aria-label={schema.title}
/>
);
}
return (
<Input
value={textValue}
onChange={handleChange}
value={text}
onChange={handleInputChange}
onBlur={handleBlur}
disabled={disabled}
readOnly={readonly}
placeholder={placeholder}

View File

@ -1,50 +0,0 @@
// Textarea Array Widget - displays an array of strings as a multiline textarea
// Each line in the textarea corresponds to one item in the array.
import type { WidgetProps } from "@rjsf/utils";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { getSizedFieldClassName } from "../utils";
import { useCallback } from "react";
export function TextareaArrayWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, onBlur, onFocus, schema, options } =
props;
// Convert array to newline-separated text for display
let textValue = "";
if (Array.isArray(value) && value.length > 0) {
textValue = value.join("\n");
} else if (typeof value === "string") {
textValue = value;
}
const fieldClassName = getSizedFieldClassName(options, "md");
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value;
if (text === "") {
onChange([]);
return;
}
// Split by newlines and filter out empty lines
const items = text.split("\n").filter((line) => line.trim() !== "");
onChange(items);
},
[onChange],
);
return (
<Textarea
id={id}
className={cn("text-md", fieldClassName)}
value={textValue}
disabled={disabled || readonly}
rows={(options.rows as number) || 3}
onChange={handleChange}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}