fix: widget edit crash (#185) + resizable textarea for complex fields
WidgetConfigDialog crashed on edit with React error #185 (Maximum update depth exceeded) when the references/instances query returned undefined and the inline '= []' fallback created a new array ref every render, looping the auto-edit useEffect. Stabilize via useMemo(data ?? []). Also moved the referencedWidgetIds Set inside the availableWidgets useMemo (clears the pre-existing exhaustive-deps warning). Complex config fields (promql, query, text, command, notes, or opt-in via format: 'textarea') now render as a taller resizable Textarea (rows=6, min-h-120px, font-mono, resize) in both WidgetConfigFields and ServiceConfigFields, instead of a single-line Input. Build + lint clean (referencedWidgetIds warning gone), 165 vitest pass.
This commit is contained in:
@@ -155,12 +155,14 @@ function WidgetConfigEditor({
|
|||||||
const isNumber =
|
const isNumber =
|
||||||
(schema as { type?: string }).type === "integer" ||
|
(schema as { type?: string }).type === "integer" ||
|
||||||
(schema as { type?: string }).type === "number";
|
(schema as { type?: string }).type === "number";
|
||||||
// Use a multi-line textarea for fields that tend to hold complex
|
// Use a multi-line resizable textarea for fields that tend to hold
|
||||||
// multi-line values (PromQL, text blocks, etc.). The widget kind's
|
// complex multi-line values (PromQL expressions, Grafana query strings,
|
||||||
// config schema can opt in via `format: "textarea"`; the well-known
|
// markdown/text blocks, etc.). The widget kind's config schema can opt
|
||||||
// `query` field is treated as textarea by default.
|
// in via `format: "textarea"`; the well-known field names below are
|
||||||
|
// treated as textarea by default.
|
||||||
const schemaFormat = (schema as { format?: string }).format;
|
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 (
|
return (
|
||||||
<Field
|
<Field
|
||||||
key={key}
|
key={key}
|
||||||
@@ -171,8 +173,8 @@ function WidgetConfigEditor({
|
|||||||
{isTextarea ? (
|
{isTextarea ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`widget-cfg-${key}`}
|
id={`widget-cfg-${key}`}
|
||||||
rows={4}
|
rows={6}
|
||||||
className="resize-y font-mono text-xs"
|
className="resize font-mono text-xs min-h-[120px]"
|
||||||
value={String(config[key] ?? "")}
|
value={String(config[key] ?? "")}
|
||||||
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -209,15 +211,25 @@ export function WidgetConfigDialog({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
// When editing a dashboard (no serviceId), scope to dashboard-only widgets
|
// 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.
|
// (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,
|
||||||
!serviceId && dashboardScope ? "dashboard" : undefined,
|
!serviceId && dashboardScope ? "dashboard" : undefined,
|
||||||
);
|
);
|
||||||
const { data: services = [] } = useServiceInstances();
|
const instances = useMemo(() => instancesData ?? [], [instancesData]);
|
||||||
const { data: tasks = [] } = useTasks();
|
const { data: servicesData } = useServiceInstances();
|
||||||
|
const services = useMemo(() => servicesData ?? [], [servicesData]);
|
||||||
|
const { data: tasksData } = useTasks();
|
||||||
|
const tasks = useMemo(() => tasksData ?? [], [tasksData]);
|
||||||
const saveWidget = useSaveWidgetInstance();
|
const saveWidget = useSaveWidgetInstance();
|
||||||
const deleteWidget = useDeleteWidgetInstance();
|
const deleteWidget = useDeleteWidgetInstance();
|
||||||
const { data: references = [] } = useWidgetReferences(dashboardScope);
|
const { data: referencesData } = useWidgetReferences(dashboardScope);
|
||||||
|
const references = useMemo(() => referencesData ?? [], [referencesData]);
|
||||||
const createRef = useCreateWidgetReference();
|
const createRef = useCreateWidgetReference();
|
||||||
const deleteRef = useDeleteWidgetReference();
|
const deleteRef = useDeleteWidgetReference();
|
||||||
const detachRef = useDetachWidgetReference();
|
const detachRef = useDetachWidgetReference();
|
||||||
@@ -367,14 +379,15 @@ export function WidgetConfigDialog({
|
|||||||
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
(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
|
// 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 availableWidgets = useMemo(() => {
|
||||||
const onDashboard = new Set([
|
const onDashboard = new Set([
|
||||||
...instances.map((w) => w.id),
|
...instances.map((w) => w.id),
|
||||||
...referencedWidgetIds,
|
...references.map((r) => r.widget_id),
|
||||||
]);
|
]);
|
||||||
const search = existingSearch.toLowerCase().trim();
|
const search = existingSearch.toLowerCase().trim();
|
||||||
return allWidgets
|
return allWidgets
|
||||||
@@ -384,8 +397,8 @@ export function WidgetConfigDialog({
|
|||||||
!search ||
|
!search ||
|
||||||
w.title.toLowerCase().includes(search) ||
|
w.title.toLowerCase().includes(search) ||
|
||||||
w.widget_kind.toLowerCase().includes(search),
|
w.widget_kind.toLowerCase().includes(search),
|
||||||
);
|
);
|
||||||
}, [allWidgets, instances, referencedWidgetIds, existingSearch]);
|
}, [allWidgets, instances, references, existingSearch]);
|
||||||
|
|
||||||
async function handleAddReference(widgetId: string) {
|
async function handleAddReference(widgetId: string) {
|
||||||
await createRef.mutateAsync({
|
await createRef.mutateAsync({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -99,20 +100,33 @@ function ServiceConfigFields({
|
|||||||
const properties =
|
const properties =
|
||||||
(
|
(
|
||||||
type.config_schema as {
|
type.config_schema as {
|
||||||
properties?: Record<string, { type?: string; description?: string }>;
|
properties?: Record<string, { type?: string; description?: string; format?: string }>;
|
||||||
}
|
}
|
||||||
).properties ?? {};
|
).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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{Object.entries(properties).map(([key, schema]) => {
|
{Object.entries(properties).map(([key, schema]) => {
|
||||||
const isNumber = schema.type === "integer" || schema.type === "number";
|
const isNumber = schema.type === "integer" || schema.type === "number";
|
||||||
|
const isTextarea = schema.format === "textarea" || TEXTAREA_KEYS.has(key);
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
key={key}
|
key={key}
|
||||||
label={key}
|
label={key}
|
||||||
htmlFor={`cfg-${key}`}
|
htmlFor={`cfg-${key}`}
|
||||||
helper={schema.description}
|
helper={schema.description}
|
||||||
>
|
>
|
||||||
|
{isTextarea ? (
|
||||||
|
<Textarea
|
||||||
|
id={`cfg-${key}`}
|
||||||
|
rows={6}
|
||||||
|
className="resize font-mono text-xs min-h-[120px]"
|
||||||
|
value={String(config[key] ?? "")}
|
||||||
|
onChange={(e) => onChange({ ...config, [key]: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id={`cfg-${key}`}
|
id={`cfg-${key}`}
|
||||||
type={isNumber ? "number" : "text"}
|
type={isNumber ? "number" : "text"}
|
||||||
@@ -128,7 +142,8 @@ function ServiceConfigFields({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
)}
|
||||||
|
</Field>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user