From 0bd2bdc1c1742814350f1a42058a051db955770f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:05:00 -0500 Subject: [PATCH] reuse widget and refactor for multiline --- .../config-form/section-configs/review.ts | 3 +- .../config-form/theme/frigateTheme.ts | 2 - .../theme/widgets/ArrayAsTextWidget.tsx | 106 ++++++++++++++---- .../theme/widgets/TextareaArrayWidget.tsx | 50 --------- 4 files changed, 89 insertions(+), 72 deletions(-) delete mode 100644 web/src/components/config-form/theme/widgets/TextareaArrayWidget.tsx diff --git a/web/src/components/config-form/section-configs/review.ts b/web/src/components/config-form/section-configs/review.ts index 3bd446e31..d1909e926 100644 --- a/web/src/components/config-form/section-configs/review.ts +++ b/web/src/components/config-form/section-configs/review.ts @@ -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: { diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 9bee54529..5497e35b7 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -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, diff --git a/web/src/components/config-form/theme/widgets/ArrayAsTextWidget.tsx b/web/src/components/config-form/theme/widgets/ArrayAsTextWidget.tsx index 7e8ab9c57..21bba78cc 100644 --- a/web/src/components/config-form/theme/widgets/ArrayAsTextWidget.tsx +++ b/web/src/components/config-form/theme/widgets/ArrayAsTextWidget.tsx @@ -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) => { - 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) => { + const raw = e.target.value; + setText(raw); + onChange(textToArray(raw, multiline)); }, - [onChange], + [onChange, multiline], ); + const handleBlur = useCallback( + (e: React.FocusEvent) => { + // 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 ( +