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 =
|
||||
(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 (
|
||||
<Field
|
||||
key={key}
|
||||
@@ -171,8 +173,8 @@ function WidgetConfigEditor({
|
||||
{isTextarea ? (
|
||||
<Textarea
|
||||
id={`widget-cfg-${key}`}
|
||||
rows={4}
|
||||
className="resize-y font-mono text-xs"
|
||||
rows={6}
|
||||
className="resize font-mono text-xs min-h-[120px]"
|
||||
value={String(config[key] ?? "")}
|
||||
onChange={(e) => 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({
|
||||
|
||||
Reference in New Issue
Block a user