diff --git a/frontend/src/components/WidgetConfigDialog.tsx b/frontend/src/components/WidgetConfigDialog.tsx index ef341a4..f4bfaa8 100644 --- a/frontend/src/components/WidgetConfigDialog.tsx +++ b/frontend/src/components/WidgetConfigDialog.tsx @@ -155,12 +155,14 @@ function WidgetConfigEditor({ const isNumber = (schema as { type?: string }).type === "integer" || (schema as { type?: string }).type === "number"; - // Use a multi-line textarea for fields that tend to hold complex - // multi-line values (PromQL, text blocks, etc.). The widget kind's - // config schema can opt in via `format: "textarea"`; the well-known - // `query` field is treated as textarea by default. + // Use a multi-line resizable textarea for fields that tend to hold + // complex multi-line values (PromQL expressions, Grafana query strings, + // markdown/text blocks, etc.). The widget kind's config schema can opt + // in via `format: "textarea"`; the well-known field names below are + // treated as textarea by default. const schemaFormat = (schema as { format?: string }).format; - const isTextarea = schemaFormat === "textarea" || key === "query"; + const TEXTAREA_KEYS = new Set(["promql", "query", "text", "command", "notes"]); + const isTextarea = schemaFormat === "textarea" || TEXTAREA_KEYS.has(key); return ( onChange({ ...config, [key]: e.target.value })} /> @@ -209,15 +211,25 @@ export function WidgetConfigDialog({ }: Props) { // When editing a dashboard (no serviceId), scope to dashboard-only widgets // (service_id IS NULL) so service-scoped widgets don't leak into the list. - const { data: instances = [] } = useWidgetInstances( + // NOTE: stabilize the `data ?? []` defaults via useMemo. Using the inline + // `= []` fallback would create a NEW array reference on every render, which + // feeds the auto-edit useEffect below (deps include `instances`/`references`) + // and causes React error #185 (Maximum update depth exceeded) when the + // underlying query returns undefined — e.g. editing a service-scoped widget + // where `dashboardScope` is undefined and `useWidgetReferences` yields no data. + const { data: instancesData } = useWidgetInstances( serviceId, !serviceId && dashboardScope ? "dashboard" : undefined, ); - const { data: services = [] } = useServiceInstances(); - const { data: tasks = [] } = useTasks(); + const instances = useMemo(() => instancesData ?? [], [instancesData]); + const { data: servicesData } = useServiceInstances(); + const services = useMemo(() => servicesData ?? [], [servicesData]); + const { data: tasksData } = useTasks(); + const tasks = useMemo(() => tasksData ?? [], [tasksData]); const saveWidget = useSaveWidgetInstance(); const deleteWidget = useDeleteWidgetInstance(); - const { data: references = [] } = useWidgetReferences(dashboardScope); + const { data: referencesData } = useWidgetReferences(dashboardScope); + const references = useMemo(() => referencesData ?? [], [referencesData]); const createRef = useCreateWidgetReference(); const deleteRef = useDeleteWidgetReference(); const detachRef = useDetachWidgetReference(); @@ -367,14 +379,15 @@ export function WidgetConfigDialog({ (a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at, ); - const referencedWidgetIds = new Set(references.map((r) => r.widget_id)); - // Available widgets for the "Add existing" picker: all widgets not already - // on this dashboard (owned or referenced). + // on this dashboard (owned or referenced). The referenced-id Set is built + // INSIDE the memo so its identity is stable across renders (building it in + // the render body would change the memo's deps every render and recompute + // it every frame — the lint-flagged footgun). const availableWidgets = useMemo(() => { const onDashboard = new Set([ ...instances.map((w) => w.id), - ...referencedWidgetIds, + ...references.map((r) => r.widget_id), ]); const search = existingSearch.toLowerCase().trim(); return allWidgets @@ -384,8 +397,8 @@ export function WidgetConfigDialog({ !search || w.title.toLowerCase().includes(search) || w.widget_kind.toLowerCase().includes(search), - ); - }, [allWidgets, instances, referencedWidgetIds, existingSearch]); + ); + }, [allWidgets, instances, references, existingSearch]); async function handleAddReference(widgetId: string) { await createRef.mutateAsync({ diff --git a/frontend/src/pages/ServicesPage.tsx b/frontend/src/pages/ServicesPage.tsx index 39085d6..c55d410 100644 --- a/frontend/src/pages/ServicesPage.tsx +++ b/frontend/src/pages/ServicesPage.tsx @@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { Dialog, @@ -99,20 +100,33 @@ function ServiceConfigFields({ const properties = ( type.config_schema as { - properties?: Record; + properties?: Record; } ).properties ?? {}; + // Multi-line resizable textarea for fields that hold complex values (opt-in + // via `format: "textarea"`, or well-known multi-line keys). + const TEXTAREA_KEYS = new Set(["notes", "command"]); return (
{Object.entries(properties).map(([key, schema]) => { const isNumber = schema.type === "integer" || schema.type === "number"; + const isTextarea = schema.format === "textarea" || TEXTAREA_KEYS.has(key); return ( - + + {isTextarea ? ( +