ecabc65dd4
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.
755 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|