add popover to show what fields will be saved

This commit is contained in:
Josh Hawkins 2026-02-08 19:11:31 -06:00
parent 7d92fade91
commit 4dd12ac934
3 changed files with 271 additions and 8 deletions

View File

@ -91,6 +91,23 @@
"desc": "Do you want to save your changes before continuing?"
}
},
"saveAllPreview": {
"title": "Changes to be saved",
"triggerLabel": "Review pending changes",
"empty": "No pending changes.",
"scope": {
"label": "Scope",
"global": "Global",
"camera": "Camera: {{cameraName}}"
},
"field": {
"label": "Field"
},
"value": {
"label": "New value",
"reset": "Reset"
}
},
"cameraSetting": {
"camera": "Camera",
"noCamera": "No Camera"

View File

@ -0,0 +1,142 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuInfo, LuX } from "react-icons/lu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type SaveAllPreviewItem = {
scope: "global" | "camera";
cameraName?: string;
fieldPath: string;
value: unknown;
};
type SaveAllPreviewPopoverProps = {
items: SaveAllPreviewItem[];
className?: string;
align?: "start" | "center" | "end";
side?: "top" | "bottom" | "left" | "right";
};
export default function SaveAllPreviewPopover({
items,
className,
align = "end",
side = "bottom",
}: SaveAllPreviewPopoverProps) {
const { t } = useTranslation(["views/settings", "common"]);
const [open, setOpen] = useState(false);
const resetLabel = t("saveAllPreview.value.reset", {
ns: "views/settings",
});
const formatValue = useCallback(
(value: unknown) => {
if (value === "") return resetLabel;
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
},
[resetLabel],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn("size-8", className)}
aria-label={t("saveAllPreview.triggerLabel", {
ns: "views/settings",
})}
>
<LuInfo className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align={align}
side={side}
className="w-[90vw] max-w-sm border bg-background p-4 shadow-lg"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-primary-variant">
{t("saveAllPreview.title", { ns: "views/settings" })}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => setOpen(false)}
aria-label={t("button.close", { ns: "common" })}
>
<LuX className="size-4" />
</Button>
</div>
{items.length === 0 ? (
<div className="mt-3 text-xs text-muted-foreground">
{t("saveAllPreview.empty", { ns: "views/settings" })}
</div>
) : (
<div className="scrollbar-container mt-3 flex max-h-72 flex-col gap-2 overflow-y-auto pr-1">
{items.map((item) => {
const scopeLabel =
item.scope === "global"
? t("saveAllPreview.scope.global", {
ns: "views/settings",
})
: t("saveAllPreview.scope.camera", {
ns: "views/settings",
cameraName: item.cameraName,
});
return (
<div
key={`${item.scope}-${item.cameraName ?? "global"}-${
item.fieldPath
}`}
className="rounded-md border border-secondary bg-background_alt p-2"
>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<span className="text-muted-foreground">
{t("saveAllPreview.scope.label", {
ns: "views/settings",
})}
</span>
<span className="truncate">{scopeLabel}</span>
<span className="text-muted-foreground">
{t("saveAllPreview.field.label", {
ns: "views/settings",
})}
</span>
<span className="font-mono break-words">
{item.fieldPath}
</span>
<span className="text-muted-foreground">
{t("saveAllPreview.value.label", {
ns: "views/settings",
})}
</span>
<span className="font-mono whitespace-pre-wrap break-words">
{formatValue(item.value)}
</span>
</div>
</div>
);
})}
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@ -87,6 +87,9 @@ import { RJSFSchema } from "@rjsf/utils";
import { prepareSectionSavePayload } from "@/utils/configUtil";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import SaveAllPreviewPopover, {
type SaveAllPreviewItem,
} from "@/components/overlay/detail/SaveAllPreviewPopover";
import { useRestart } from "@/api/ws";
const allSettingsViews = [
@ -152,6 +155,42 @@ const allSettingsViews = [
] as const;
type SettingsType = (typeof allSettingsViews)[number];
const parsePendingDataKey = (pendingDataKey: string) => {
if (pendingDataKey.includes("::")) {
const idx = pendingDataKey.indexOf("::");
return {
scope: "camera" as const,
cameraName: pendingDataKey.slice(0, idx),
sectionPath: pendingDataKey.slice(idx + 2),
};
}
return {
scope: "global" as const,
cameraName: undefined,
sectionPath: pendingDataKey,
};
};
const flattenOverrides = (
value: unknown,
path: string[] = [],
): Array<{ path: string; value: unknown }> => {
if (value === undefined) return [];
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return [{ path: path.join("."), value }];
}
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) {
return [{ path: path.join("."), value: {} }];
}
return entries.flatMap(([key, entryValue]) =>
flattenOverrides(entryValue, [...path, key]),
);
};
const createSectionPage = (
sectionKey: string,
level: "global" | "camera",
@ -620,6 +659,42 @@ export default function Settings() {
const { data: fullSchema } = useSWR<RJSFSchema>("config/schema.json");
const hasPendingChanges = Object.keys(pendingDataBySection).length > 0;
const pendingChangesPreview = useMemo<SaveAllPreviewItem[]>(() => {
if (!config || !fullSchema) return [];
const items: SaveAllPreviewItem[] = [];
Object.entries(pendingDataBySection).forEach(
([pendingDataKey, pendingData]) => {
const payload = prepareSectionSavePayload({
pendingDataKey,
pendingData,
config,
fullSchema,
});
if (!payload) return;
const { scope, cameraName, sectionPath } =
parsePendingDataKey(pendingDataKey);
const flattened = flattenOverrides(payload.sanitizedOverrides);
flattened.forEach(({ path, value }) => {
const fieldPath = path ? `${sectionPath}.${path}` : sectionPath;
items.push({ scope, cameraName, fieldPath, value });
});
},
);
return items.sort((left, right) => {
const scopeCompare = left.scope.localeCompare(right.scope);
if (scopeCompare !== 0) return scopeCompare;
const cameraCompare = (left.cameraName ?? "").localeCompare(
right.cameraName ?? "",
);
if (cameraCompare !== 0) return cameraCompare;
return left.fieldPath.localeCompare(right.fieldPath);
});
}, [config, fullSchema, pendingDataBySection]);
// Map a pendingDataKey to SettingsType menu key for clearing section status
const pendingKeyToMenuKey = useCallback(
@ -982,8 +1057,17 @@ export default function Settings() {
{!contentMobileOpen && (
<div className="flex size-full flex-col">
<div className="sticky -top-2 z-50 mb-2 bg-background p-4">
<div className="flex items-center justify-center">
<div className="relative flex w-full items-center justify-center">
<Logo className="h-8" />
<div className="absolute right-0">
<CameraSelectButton
allCameras={cameras}
selectedCamera={selectedCamera}
setSelectedCamera={setSelectedCamera}
cameraEnabledStates={cameraEnabledStates}
currentPage={page}
/>
</div>
</div>
<div className="flex flex-row text-center">
<h2 className="ml-2 text-lg">
@ -1035,12 +1119,20 @@ export default function Settings() {
{hasPendingChanges && (
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
<div className="flex flex-col items-center gap-2">
<span className="text-sm text-danger">
{t("unsavedChanges", {
ns: "views/settings",
defaultValue: "You have unsaved changes",
})}
</span>
<div className="flex items-center gap-2">
<span className="text-sm text-danger">
{t("unsavedChanges", {
ns: "views/settings",
defaultValue: "You have unsaved changes",
})}
</span>
<SaveAllPreviewPopover
items={pendingChangesPreview}
className="h-7 w-7"
align="center"
side="top"
/>
</div>
<Button
onClick={handleUndoAll}
@ -1169,7 +1261,19 @@ export default function Settings() {
</Heading>
<div className="flex items-center gap-5">
{hasPendingChanges && (
<div className="flex flex-row gap-2 border-r border-secondary pr-5">
<div
className={cn(
"flex flex-row items-center gap-2",
CAMERA_SELECT_BUTTON_PAGES.includes(page) &&
"border-r border-secondary pr-5",
)}
>
<SaveAllPreviewPopover
items={pendingChangesPreview}
className="size-8"
align="end"
side="bottom"
/>
<Button
onClick={handleUndoAll}
variant="outline"