Files
manage/frontend/src/components/WidgetConfigDialog.tsx
T
Developer ecabc65dd4 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.
2026-07-11 10:31:57 +00:00

755 lines
22 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
ChevronDown,
ChevronUp,
Link2,
Pencil,
Plus,
Trash2,
Split,
} from "lucide-react";
import {
useCreateWidgetReference,
useDeleteWidgetInstance,
useDeleteWidgetReference,
useDetachWidgetReference,
useSaveWidgetInstance,
useUpdateWidgetReference,
useWidgetInstances,
useWidgetReferences,
} from "../hooks/useWidgets";
import { useServiceInstances } from "../hooks/useServices";
import { useTasks } from "../hooks/useSettings";
import { useIsMobile } from "../hooks/useIsMobile";
import { SheetForm } from "@/components/ui/sheet-form";
import type { WidgetInstance, WidgetInstanceInput } from "../types";
import {
BUILTIN_WIDGETS,
SERVICE_REGISTRY,
type ServiceWidgetBinding,
} from "../integrations/registry";
interface Props {
open: boolean;
onClose: () => void;
/** When set, scope the dialog to a specific service instance's widgets. */
serviceId?: string;
/** When set, enable widget references ("Add existing") for this dashboard scope. */
dashboardScope?: string;
/** When set, auto-open in edit mode for this widget id (instead of the list view). */
editWidgetId?: string;
}
interface Draft {
id?: string;
serviceId: string | null;
widgetKind: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sortOrder: number;
}
function Field({
label,
htmlFor,
helper,
children,
}: {
label: string;
htmlFor: string;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
{helper ? (
<p className="text-xs text-muted-foreground">{helper}</p>
) : null}
</div>
);
}
function bindingLabel(serviceId: string | null, widgetKind: string): string {
if (serviceId === null)
return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind;
return widgetKind;
}
function WidgetConfigEditor({
binding,
isTaskOutput,
config,
onChange,
tasks,
}: {
binding: ServiceWidgetBinding | undefined;
isTaskOutput: boolean;
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
tasks: { id: string; name: string; enabled: boolean }[];
}) {
// SSH task output gets a dedicated task picker; everything else gets a
// generic text field per top-level schema property.
if (isTaskOutput) {
return (
<Field label="Saved task" htmlFor="widget-task-id">
<Select
value={String(config.task_id ?? "")}
onValueChange={(v) => onChange({ ...config, task_id: v })}
>
<SelectTrigger id="widget-task-id">
<SelectValue placeholder="Select a task" />
</SelectTrigger>
<SelectContent>
{tasks
.filter((t) => t.enabled)
.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
const properties = binding
? Object.entries(
(
binding.configSchema as
| { properties?: Record<string, unknown> }
| undefined
)?.properties ?? {},
)
: [];
if (properties.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{properties.map(([key, schema]) => {
const isNumber =
(schema as { type?: string }).type === "integer" ||
(schema as { type?: string }).type === "number";
// 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 TEXTAREA_KEYS = new Set(["promql", "query", "text", "command", "notes"]);
const isTextarea = schemaFormat === "textarea" || TEXTAREA_KEYS.has(key);
return (
<Field
key={key}
label={key}
htmlFor={`widget-cfg-${key}`}
helper={(schema as { description?: string }).description}
>
{isTextarea ? (
<Textarea
id={`widget-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
id={`widget-cfg-${key}`}
type={isNumber ? "number" : "text"}
value={String(config[key] ?? "")}
onChange={(e) =>
onChange({
...config,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
)}
</Field>
);
})}
</div>
);
}
export function WidgetConfigDialog({
open,
onClose,
serviceId,
dashboardScope,
editWidgetId,
}: 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.
// 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 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: referencesData } = useWidgetReferences(dashboardScope);
const references = useMemo(() => referencesData ?? [], [referencesData]);
const createRef = useCreateWidgetReference();
const deleteRef = useDeleteWidgetReference();
const detachRef = useDetachWidgetReference();
const updateRef = useUpdateWidgetReference();
const { data: allWidgets = [] } = useWidgetInstances();
const [showExisting, setShowExisting] = useState(false);
const [existingSearch, setExistingSearch] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [draftBaseline, setDraftBaseline] = useState<Draft | null>(null);
// When opened via editWidgetId, closing the edit should close the dialog
// entirely (not fall back to the list view).
const directEdit = Boolean(editWidgetId);
// When editWidgetId is set and the dialog opens, auto-enter edit mode for
// that widget (instead of showing the list view).
useEffect(() => {
if (open && editWidgetId) {
// Search both owned widgets and referenced widgets.
const target =
instances.find((w) => w.id === editWidgetId) ??
references.find((r) => r.widget.id === editWidgetId)?.widget;
if (target) {
startEdit(target);
}
}
}, [open, editWidgetId, instances, references]);
function startAddBuiltIn(kind: string) {
const binding = BUILTIN_WIDGETS[kind];
setDraft({
serviceId: serviceId ?? null,
widgetKind: kind,
title: binding?.name ?? kind,
config: { ...(binding?.defaultConfig ?? {}) },
enabled: true,
sortOrder: 0,
});
}
function startAddService(serviceId: string, kind: string) {
const binding = SERVICE_REGISTRY[
services.find((s) => s.id === serviceId)?.service_type ?? ""
]?.widgets.find((w) => w.kind === kind);
setDraft({
serviceId,
widgetKind: kind,
title: binding?.name ?? kind,
config: { ...(binding?.defaultConfig ?? {}) },
enabled: true,
sortOrder: 0,
});
}
function startEdit(instance: WidgetInstance) {
const d: Draft = {
id: instance.id,
serviceId: instance.service_id,
widgetKind: instance.widget_kind,
title: instance.title,
config: instance.config,
enabled: instance.enabled,
sortOrder: instance.sort_order,
};
setDraft(d);
setDraftBaseline(d);
}
function reset() {
// If we were opened via direct edit, closing should close the dialog
// entirely, not fall back to the list view.
if (directEdit) {
onClose();
return;
}
setDraft(null);
setDraftBaseline(null);
}
async function saveDraft() {
if (!draft) return;
const input: WidgetInstanceInput = {
id: draft.id ?? null,
service_id: draft.serviceId,
widget_kind: draft.widgetKind,
title: draft.title,
config: draft.config,
enabled: draft.enabled,
sort_order: draft.sortOrder,
};
await saveWidget.mutateAsync(input);
reset();
}
async function toggleEnabled(instance: WidgetInstance) {
await saveWidget.mutateAsync({
id: instance.id,
service_id: instance.service_id,
widget_kind: instance.widget_kind,
title: instance.title,
config: instance.config,
enabled: !instance.enabled,
sort_order: instance.sort_order,
});
}
async function moveInstance(index: number, direction: -1 | 1) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= combinedWidgets.length) return;
// Swap the two items in a copy, then renumber ALL items by their new
// index position (index * 10). This guarantees the sort_order values
// change even when both items previously shared the same value (e.g. 0).
const reordered = [...combinedWidgets];
const tmp = reordered[index];
reordered[index] = reordered[targetIndex];
reordered[targetIndex] = tmp;
// Sequential (not Promise.all) to avoid cache-invalidation race.
for (let i = 0; i < reordered.length; i++) {
const item = reordered[i];
const newSortOrder = i * 10;
const refId = (item as { _ref_id?: string })._ref_id;
if (refId) {
await updateRef.mutateAsync({
referenceId: refId,
sortOrder: newSortOrder,
});
} else {
await saveWidget.mutateAsync({ ...item, sort_order: newSortOrder });
}
}
}
async function removeInstance(instance: WidgetInstance) {
await deleteWidget.mutateAsync(instance.id);
}
// Build a combined view of owned widgets + references for display.
const owned = [...instances].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
);
const refs = references.map((r) => ({
...r.widget,
_ref_id: r.id,
_is_reference: true as const,
}));
const combinedWidgets = [...owned, ...refs].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
);
// Available widgets for the "Add existing" picker: all widgets not already
// 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),
...references.map((r) => r.widget_id),
]);
const search = existingSearch.toLowerCase().trim();
return allWidgets
.filter((w) => !onDashboard.has(w.id))
.filter(
(w) =>
!search ||
w.title.toLowerCase().includes(search) ||
w.widget_kind.toLowerCase().includes(search),
);
}, [allWidgets, instances, references, existingSearch]);
async function handleAddReference(widgetId: string) {
await createRef.mutateAsync({
dashboard_scope: dashboardScope!,
widget_id: widgetId,
});
}
async function handleRemoveReference(refId: string) {
await deleteRef.mutateAsync(refId);
}
async function handleDetach(refId: string) {
await detachRef.mutateAsync(refId);
}
function handleClose(next: boolean) {
if (!next) {
reset();
onClose();
}
}
const draftBinding = draft
? draft.serviceId
? SERVICE_REGISTRY[
services.find((s) => s.id === draft.serviceId)?.service_type ?? ""
]?.widgets.find((w) => w.kind === draft.widgetKind)
: BUILTIN_WIDGETS[draft.widgetKind]
: undefined;
const isMobile = useIsMobile();
const isTaskOutput =
draft?.serviceId !== null &&
services.find((s) => s.id === draft?.serviceId)?.service_type ===
"ssh_tasks";
// The draft body (Title/SortOrder/Enabled/config editor) is shared between
// the Dialog (desktop) and SheetForm (mobile). On mobile the inline
// Back/Save buttons are omitted because the SheetForm footer provides them.
const draftBody = draft ? (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Title" htmlFor="widget-title">
<Input
id="widget-title"
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
/>
</Field>
<Field label="Sort order" htmlFor="widget-sort-order">
<Input
id="widget-sort-order"
type="number"
value={String(draft.sortOrder)}
onChange={(e) =>
setDraft({
...draft,
sortOrder: e.target.value === "" ? 0 : Number(e.target.value),
})
}
/>
</Field>
</div>
<div className="flex items-center gap-2">
<Switch
id="widget-enabled"
className="mobile-touch-target"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
}
/>
<Label htmlFor="widget-enabled">Enabled</Label>
</div>
<WidgetConfigEditor
binding={draftBinding}
isTaskOutput={!!isTaskOutput}
config={draft.config}
onChange={(config) => setDraft({ ...draft, config })}
tasks={tasks}
/>
{!isMobile ? (
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={reset}
className="mobile-touch-target"
>
Back
</Button>
<Button
onClick={saveDraft}
disabled={saveWidget.isPending}
className="mobile-touch-target"
>
Save widget
</Button>
</div>
) : null}
</div>
) : (
<div className="flex flex-col gap-4">
{combinedWidgets.length === 0 ? (
<Alert>
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
</Alert>
) : (
<div className="flex flex-col gap-2">
{combinedWidgets.map((instance, index) => {
const serviceName = instance.service_id
? services.find((s) => s.id === instance.service_id)?.name
: "Built-in";
const isRef =
(instance as { _is_reference?: boolean })._is_reference === true;
const refId = (instance as { _ref_id?: string })._ref_id;
return (
<div
key={instance.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{instance.title}</span>
{isRef ? (
<Badge variant="secondary">
<Link2 className="mr-1 h-3 w-3" />
linked
</Badge>
) : null}
<Badge variant="outline">
{bindingLabel(instance.service_id, instance.widget_kind)}
</Badge>
{serviceName ? (
<span className="text-xs text-muted-foreground">
{serviceName}
</span>
) : null}
{!instance.enabled ? (
<Badge variant="secondary">disabled</Badge>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
disabled={index === 0}
onClick={() => moveInstance(index, -1)}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
disabled={index === combinedWidgets.length - 1}
onClick={() => moveInstance(index, 1)}
>
<ChevronDown className="h-4 w-4" />
</Button>
{!isRef ? (
<Switch
className="mobile-touch-target"
checked={instance.enabled}
onCheckedChange={() => toggleEnabled(instance)}
aria-label={`Toggle ${instance.title}`}
/>
) : null}
{!isRef ? (
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
onClick={() => startEdit(instance)}
>
<Pencil className="h-4 w-4" />
</Button>
) : null}
{isRef && refId ? (
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
title="Make an independent copy"
onClick={() => handleDetach(refId)}
>
<Split className="h-4 w-4" />
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8 text-destructive"
title={
isRef ? "Remove from this dashboard" : "Delete widget"
}
onClick={() =>
isRef && refId
? handleRemoveReference(refId)
: removeInstance(instance)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
)}
{dashboardScope ? (
<div className="flex flex-col gap-2">
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => setShowExisting(!showExisting)}
>
<Link2 className="mr-1 h-3 w-3" />
{showExisting ? "Hide" : "Add existing widget"}
</Button>
{showExisting ? (
<div className="flex flex-col gap-2">
<Input
placeholder="Search widgets..."
value={existingSearch}
onChange={(e) => setExistingSearch(e.target.value)}
/>
{availableWidgets.length === 0 ? (
<p className="text-xs text-muted-foreground">
No widgets available to reuse.
</p>
) : (
<div className="flex flex-col gap-1">
{availableWidgets.map((w) => {
const owner = w.service_id
? services.find((s) => s.id === w.service_id)?.name
: "Dashboard";
return (
<div
key={w.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col">
<span className="text-sm font-medium">{w.title}</span>
<span className="text-xs text-muted-foreground">
{bindingLabel(w.service_id, w.widget_kind)} ·{" "}
{owner}
</span>
</div>
<Button
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => handleAddReference(w.id)}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
);
})}
</div>
)}
</div>
) : null}
</div>
) : null}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Add widget</p>
<div className="flex flex-wrap gap-2">
{Object.values(BUILTIN_WIDGETS).map((b) => (
<Button
key={b.kind}
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => startAddBuiltIn(b.kind)}
>
<Plus className="mr-1 h-3 w-3" />
{b.name}
</Button>
))}
{services
.filter((s) => s.enabled)
// When scoped to a service Overview, only show widgets for THAT
// service instance's type (not all services' widgets).
.filter((s) => !serviceId || s.id === serviceId)
.flatMap((s) =>
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
<Button
key={`${s.id}:${w.kind}`}
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => startAddService(s.id, w.kind)}
>
<Plus className="mr-1 h-3 w-3" />
{w.name} · {s.name}
</Button>
)),
)}
</div>
<p className="text-xs text-muted-foreground">
Configure services on their service pages to unlock more widgets.
</p>
</div>
</div>
);
const dialogTitle = draft
? draft.id
? "Edit widget"
: "Add widget"
: "Dashboard widgets";
if (isMobile) {
return (
<SheetForm
open={open}
onOpenChange={(next) => {
if (!next) handleClose(next);
}}
title={dialogTitle}
onSave={draft ? saveDraft : () => handleClose(false)}
onCancel={draft ? reset : () => handleClose(false)}
saveLabel={draft ? "Save widget" : "Done"}
isPending={draft ? saveWidget.isPending : false}
isDirty={
draft !== null && draftBaseline !== null
? JSON.stringify(draft) !== JSON.stringify(draftBaseline)
: draft !== null && draft?.id === undefined
}
>
<div className="flex flex-col gap-4">{draftBody}</div>
</SheetForm>
);
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
{draftBody}
</DialogContent>
</Dialog>
);
}