use rjsf for notifications view

This commit is contained in:
Josh Hawkins 2026-02-11 13:13:47 -06:00
parent 0db6e87413
commit 093a25e9ac
6 changed files with 883 additions and 16 deletions

View File

@ -9,6 +9,15 @@ const notifications: SectionConfigOverrides = {
hiddenFields: ["enabled_in_config"],
advancedFields: [],
},
global: {
uiSchema: {
"ui:before": { render: "NotificationsSettingsExtras" },
enabled: { "ui:widget": "hidden" },
email: { "ui:widget": "hidden" },
cooldown: { "ui:widget": "hidden" },
enabled_in_config: { "ui:widget": "hidden" },
},
},
camera: {
hiddenFields: ["enabled_in_config", "email"],
},

View File

@ -0,0 +1,837 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import Heading from "@/components/ui/heading";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Toaster } from "@/components/ui/sonner";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useForm } from "react-hook-form";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { CiCircleAlert } from "react-icons/ci";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import useSWR from "swr";
import { z } from "zod";
import {
useNotifications,
useNotificationSuspend,
useNotificationTest,
} from "@/api/ws";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Trans, useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import set from "lodash/set";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import { sanitizeSectionData } from "@/utils/configUtil";
import type { SectionRendererProps } from "./registry";
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
export default function NotificationsSettingsExtras({
formContext,
}: SectionRendererProps) {
const { t } = useTranslation([
"views/settings",
"common",
"components/filter",
]);
const { getLocaleDocUrl } = useDocDomain();
// roles
const isAdmin = useIsAdmin();
// status bar
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
// config
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order)
.filter((c) => c.enabled_in_config);
}, [config]);
const notificationCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter(
(conf) =>
conf.enabled_in_config &&
conf.notifications &&
conf.notifications.enabled_in_config,
)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const { send: sendTestNotification } = useNotificationTest();
// notification state
const [registration, setRegistration] =
useState<ServiceWorkerRegistration | null>();
const [cameraSelectionTouched, setCameraSelectionTouched] = useState(false);
useEffect(() => {
if (!("Notification" in window) || !window.isSecureContext) {
return;
}
navigator.serviceWorker
.getRegistration(NOTIFICATION_SERVICE_WORKER)
.then((worker) => {
if (worker) {
setRegistration(worker);
} else {
setRegistration(null);
}
})
.catch(() => {
setRegistration(null);
});
}, []);
// form
const formSchema = z.object({
allEnabled: z.boolean(),
email: z.string(),
cameras: z.array(z.string()),
});
const pendingDataBySection = useMemo(
() => formContext?.pendingDataBySection ?? {},
[formContext?.pendingDataBySection],
);
const pendingCameraOverrides = useMemo(() => {
const overrides: Record<string, boolean> = {};
Object.entries(pendingDataBySection).forEach(([key, data]) => {
if (!key.endsWith("::notifications")) {
return;
}
const cameraName = key.slice(0, key.indexOf("::"));
const enabled = (data as JsonObject | undefined)?.enabled;
if (typeof enabled === "boolean") {
overrides[cameraName] = enabled;
}
});
return overrides;
}, [pendingDataBySection]);
const defaultValues = useMemo(() => {
const formData = formContext?.formData as JsonObject | undefined;
const enabledValue =
typeof formData?.enabled === "boolean"
? formData.enabled
: (config?.notifications.enabled ?? false);
const emailValue =
typeof formData?.email === "string"
? formData.email
: (config?.notifications.email ?? "");
const baseEnabledSet = new Set(
notificationCameras.map((camera) => camera.name),
);
const selectedCameras = enabledValue
? []
: allCameras
.filter((camera) => {
const pendingEnabled = pendingCameraOverrides[camera.name];
if (typeof pendingEnabled === "boolean") {
return pendingEnabled;
}
return baseEnabledSet.has(camera.name);
})
.map((camera) => camera.name);
return {
allEnabled: Boolean(enabledValue),
email: typeof emailValue === "string" ? emailValue : "",
cameras: selectedCameras,
};
}, [
allCameras,
config?.notifications.email,
config?.notifications.enabled,
formContext?.formData,
notificationCameras,
pendingCameraOverrides,
]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues,
});
const watchAllEnabled = form.watch("allEnabled");
const watchCameras = form.watch("cameras");
const watchEmail = form.watch("email");
const pendingCameraOverridesRef = useRef<Set<string>>(new Set());
const resetFormState = useCallback(
(values: z.infer<typeof formSchema>) => {
form.reset(values);
setCameraSelectionTouched(false);
pendingCameraOverridesRef.current.clear();
},
[form],
);
// pending changes sync (Undo All / Save All)
const hasPendingNotifications = useMemo(
() =>
Object.keys(pendingDataBySection).some(
(key) => key === "notifications" || key.endsWith("::notifications"),
),
[pendingDataBySection],
);
const hasPendingNotificationsRef = useRef(hasPendingNotifications);
useEffect(() => {
if (!config || form.formState.isDirty || hasPendingNotifications) {
return;
}
resetFormState(defaultValues);
}, [
config,
defaultValues,
form.formState.isDirty,
hasPendingNotifications,
resetFormState,
]);
useEffect(() => {
const hadPending = hasPendingNotificationsRef.current;
hasPendingNotificationsRef.current = hasPendingNotifications;
if (hadPending && !hasPendingNotifications) {
resetFormState(defaultValues);
}
}, [hasPendingNotifications, defaultValues, resetFormState]);
useEffect(() => {
if (!formContext?.onFormDataChange) {
return;
}
const baseData =
(formContext.formData as JsonObject | undefined) ??
(config?.notifications as JsonObject | undefined);
if (!baseData) {
return;
}
const nextData = cloneDeep(baseData);
const normalizedEmail = watchEmail?.trim() ? watchEmail : null;
set(nextData, "enabled", Boolean(watchAllEnabled));
set(nextData, "email", normalizedEmail);
formContext.onFormDataChange(nextData as ConfigSectionData);
}, [config, formContext, watchAllEnabled, watchEmail]);
// camera selection overrides
const baselineCameraSelection = useMemo(() => {
if (!config) {
return [] as string[];
}
return config.notifications.enabled
? []
: notificationCameras.map((camera) => camera.name);
}, [config, notificationCameras]);
const cameraSelectionDirty = useMemo(() => {
const current = Array.isArray(watchCameras) ? watchCameras : [];
return !isEqual([...current].sort(), [...baselineCameraSelection].sort());
}, [watchCameras, baselineCameraSelection]);
useEffect(() => {
formContext?.setExtraHasChanges?.(cameraSelectionDirty);
}, [cameraSelectionDirty, formContext]);
useEffect(() => {
const onPendingDataChange = formContext?.onPendingDataChange;
if (!onPendingDataChange || !config) {
return;
}
if (!cameraSelectionTouched) {
return;
}
if (!cameraSelectionDirty) {
pendingCameraOverridesRef.current.forEach((cameraName) => {
onPendingDataChange("notifications", cameraName, null);
});
pendingCameraOverridesRef.current.clear();
setCameraSelectionTouched(false);
return;
}
const selectedCameras = Array.isArray(watchCameras) ? watchCameras : [];
allCameras.forEach((camera) => {
const desiredEnabled = watchAllEnabled
? true
: selectedCameras.includes(camera.name);
const currentNotifications = config.cameras[camera.name]?.notifications;
const currentEnabled = currentNotifications?.enabled;
if (desiredEnabled === currentEnabled) {
if (pendingCameraOverridesRef.current.has(camera.name)) {
onPendingDataChange("notifications", camera.name, null);
pendingCameraOverridesRef.current.delete(camera.name);
}
return;
}
if (!currentNotifications) {
return;
}
const nextNotifications = cloneDeep(
currentNotifications as JsonObject,
) as JsonObject;
set(nextNotifications, "enabled", desiredEnabled);
const sanitizedNotifications = sanitizeSectionData(
nextNotifications as ConfigSectionData,
["enabled_in_config", "email"],
);
onPendingDataChange("notifications", camera.name, sanitizedNotifications);
pendingCameraOverridesRef.current.add(camera.name);
});
}, [
allCameras,
cameraSelectionDirty,
cameraSelectionTouched,
config,
formContext,
watchAllEnabled,
watchCameras,
]);
const anyCameraNotificationsEnabled = useMemo(
() =>
config &&
Object.values(config.cameras).some(
(c) =>
c.enabled_in_config &&
c.notifications &&
c.notifications.enabled_in_config,
),
[config],
);
const shouldFetchPubKey = Boolean(
config &&
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
(watchAllEnabled ||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
);
const { data: publicKey } = useSWR(
shouldFetchPubKey ? "notifications/pubkey" : null,
{ revalidateOnFocus: false },
);
const subscribeToNotifications = useCallback(
(workerRegistration: ServiceWorkerRegistration) => {
if (!workerRegistration) {
return;
}
addMessage(
"notification_settings",
t("notification.unsavedRegistrations"),
undefined,
"registration",
);
workerRegistration.pushManager
.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
})
.then((pushSubscription) => {
axios
.post("notifications/register", {
sub: pushSubscription,
})
.catch(() => {
toast.error(t("notification.toast.error.registerFailed"), {
position: "top-center",
});
pushSubscription.unsubscribe();
workerRegistration.unregister();
setRegistration(null);
});
toast.success(t("notification.toast.success.registered"), {
position: "top-center",
});
});
},
[addMessage, publicKey, t],
);
useEffect(() => {
if (watchCameras.length > 0) {
form.setValue("allEnabled", false);
}
}, [watchCameras, allCameras, form]);
useEffect(() => {
document.title = t("documentTitle.notifications");
}, [t]);
if (formContext?.level && formContext.level !== "global") {
return null;
}
if (!config) {
return <ActivityIndicator />;
}
if (!("Notification" in window) || !window.isSecureContext) {
return (
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
<div className="col-span-1">
<Heading as="h4" className="mb-2">
{t("notification.notificationSettings.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.notificationSettings.desc")}</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/notifications")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>
{t("notification.notificationUnavailable.title")}
</AlertTitle>
<AlertDescription>
<Trans ns="views/settings">
notification.notificationUnavailable.desc
</Trans>
<div className="mt-3 flex items-center">
<Link
to={getLocaleDocUrl("configuration/authentication")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
<div
className={cn(
isAdmin && "grid w-full grid-cols-1 gap-4 md:grid-cols-2",
)}
>
<div className="col-span-1">
{isAdmin && (
<Form {...form}>
<div className="mt-2 space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("notification.email.title")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
placeholder={t("notification.email.placeholder")}
{...field}
/>
</FormControl>
<FormDescription>
{t("notification.email.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cameras"
render={({ field }) => (
<FormItem>
{allCameras && allCameras?.length > 0 ? (
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
{t("notification.cameras.title")}
</FormLabel>
</div>
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
<FormField
control={form.control}
name="allEnabled"
render={({ field }) => (
<FilterSwitch
label={t("cameras.all.title", {
ns: "components/filter",
})}
isChecked={field.value}
onCheckedChange={(checked) => {
setCameraSelectionTouched(true);
if (checked) {
form.setValue("cameras", []);
}
field.onChange(checked);
}}
/>
)}
/>
{allCameras?.map((camera) => {
const currentCameras = Array.isArray(
field.value,
)
? field.value
: [];
return (
<FilterSwitch
key={camera.name}
label={camera.name}
type="camera"
isChecked={currentCameras.includes(
camera.name,
)}
onCheckedChange={(checked) => {
setCameraSelectionTouched(true);
const newCameras = checked
? Array.from(
new Set([
...currentCameras,
camera.name,
]),
)
: currentCameras.filter(
(value) => value !== camera.name,
);
field.onChange(newCameras);
form.setValue("allEnabled", false);
}}
/>
);
})}
</div>
</>
) : (
<div className="font-normal text-destructive">
{t("notification.cameras.noCameras")}
</div>
)}
<FormMessage />
<FormDescription>
{t("notification.cameras.desc")}
</FormDescription>
</FormItem>
)}
/>
</div>
</Form>
)}
</div>
<div className="col-span-1">
<div className="mt-4 gap-2 space-y-6">
<div
className={cn(isAdmin && "flex flex-col gap-2 md:max-w-[50%]")}
>
<Separator
className={cn(
"my-2 flex bg-secondary",
isAdmin && "md:hidden",
)}
/>
<Heading as="h4" className={cn(isAdmin ? "my-2" : "my-4")}>
{t("notification.deviceSpecific")}
</Heading>
<Button
aria-label={t("notification.registerDevice")}
disabled={!shouldFetchPubKey || publicKey == undefined}
onClick={() => {
if (registration == null) {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
navigator.serviceWorker
.register(NOTIFICATION_SERVICE_WORKER)
.then((workerRegistration) => {
setRegistration(workerRegistration);
if (workerRegistration.active) {
subscribeToNotifications(workerRegistration);
} else {
setTimeout(
() =>
subscribeToNotifications(
workerRegistration,
),
1000,
);
}
});
}
});
} else {
registration.pushManager
.getSubscription()
.then((pushSubscription) => {
pushSubscription?.unsubscribe();
registration.unregister();
setRegistration(null);
removeMessage(
"notification_settings",
"registration",
);
});
}
}}
>
{registration != null
? t("notification.unregisterDevice")
: t("notification.registerDevice")}
</Button>
{isAdmin && registration != null && registration.active && (
<Button
aria-label={t("notification.sendTestNotification")}
onClick={() => sendTestNotification("notification_test")}
>
{t("notification.sendTestNotification")}
</Button>
)}
</div>
</div>
{isAdmin && notificationCameras.length > 0 && (
<div className="mt-4 gap-2 space-y-6">
<div className="space-y-3">
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
{t("notification.globalSettings.title")}
</Heading>
<div className="max-w-xl">
<div className="mb-5 mt-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.globalSettings.desc")}</p>
</div>
</div>
<div className="flex max-w-2xl flex-col gap-2.5">
<div className="rounded-lg bg-secondary p-5">
<div className="grid gap-6">
{notificationCameras.map((item) => (
<CameraNotificationSwitch
key={item.name}
config={config}
camera={item.name}
/>
))}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
type CameraNotificationSwitchProps = {
config?: FrigateConfig;
camera: string;
};
export function CameraNotificationSwitch({
config,
camera,
}: CameraNotificationSwitchProps) {
const { t } = useTranslation(["views/settings"]);
const { payload: notificationState, send: sendNotification } =
useNotifications(camera);
const { payload: notificationSuspendUntil, send: sendNotificationSuspend } =
useNotificationSuspend(camera);
const [isSuspended, setIsSuspended] = useState<boolean>(false);
useEffect(() => {
if (notificationSuspendUntil) {
setIsSuspended(
notificationSuspendUntil !== "0" || notificationState === "OFF",
);
}
}, [notificationSuspendUntil, notificationState]);
const handleSuspend = (duration: string) => {
setIsSuspended(true);
if (duration == "off") {
sendNotification("OFF");
} else {
sendNotificationSuspend(parseInt(duration));
}
};
const handleCancelSuspension = () => {
sendNotification("ON");
sendNotificationSuspend(0);
};
const locale = useDateLocale();
const formatSuspendedUntil = (timestamp: string) => {
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
const time = formatUnixTimestampToDateTime(parseInt(timestamp), {
time_style: "medium",
date_style: "medium",
timezone: config?.ui.timezone,
date_format:
config?.ui.time_format == "24hour"
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
ns: "common",
})
: t("time.formattedTimestampMonthDayHourMinute.12hour", {
ns: "common",
}),
locale: locale,
});
return t("time.untilForTime", { ns: "common", time });
};
return (
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-row items-center justify-start gap-3">
{!isSuspended ? (
<LuCheck className="size-6 text-success" />
) : (
<LuX className="size-6 text-danger" />
)}
<div className="flex flex-col">
<CameraNameLabel
className="text-md cursor-pointer text-primary smart-capitalize"
htmlFor="camera"
camera={camera}
/>
{!isSuspended ? (
<div className="flex flex-row items-center gap-2 text-sm text-success">
{t("notification.active")}
</div>
) : (
<div className="flex flex-row items-center gap-2 text-sm text-danger">
{t("notification.suspended", {
time: formatSuspendedUntil(notificationSuspendUntil),
})}
</div>
)}
</div>
</div>
</div>
{!isSuspended ? (
<Select onValueChange={handleSuspend}>
<SelectTrigger className="w-auto">
<SelectValue placeholder={t("notification.suspendTime.suspend")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">
{t("notification.suspendTime.5minutes")}
</SelectItem>
<SelectItem value="10">
{t("notification.suspendTime.10minutes")}
</SelectItem>
<SelectItem value="30">
{t("notification.suspendTime.30minutes")}
</SelectItem>
<SelectItem value="60">
{t("notification.suspendTime.1hour")}
</SelectItem>
<SelectItem value="840">
{t("notification.suspendTime.12hours")}
</SelectItem>
<SelectItem value="1440">
{t("notification.suspendTime.24hours")}
</SelectItem>
<SelectItem value="off">
{t("notification.suspendTime.untilRestart")}
</SelectItem>
</SelectContent>
</Select>
) : (
<Button
variant="destructive"
size="sm"
onClick={handleCancelSuspension}
>
{t("notification.cancelSuspension")}
</Button>
)}
</div>
);
}

View File

@ -2,6 +2,7 @@ import type { ComponentType } from "react";
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
import ProxyRoleMap from "./ProxyRoleMap";
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
import type { ConfigFormContext } from "@/types/configForm";
// Props that will be injected into all section renderers
@ -48,6 +49,9 @@ export const sectionRenderers: SectionRenderers = {
proxy: {
ProxyRoleMap,
},
notifications: {
NotificationsSettingsExtras,
},
};
export default sectionRenderers;

View File

@ -193,6 +193,11 @@ export function ConfigSection({
pendingDataBySection !== undefined
? (pendingDataBySection[pendingDataKey] as ConfigSectionData | null)
: localPendingData;
const pendingDataRef = useRef<ConfigSectionData | null>(null);
useEffect(() => {
pendingDataRef.current = pendingData;
}, [pendingData]);
const setPendingData = useCallback(
(data: ConfigSectionData | null) => {
@ -205,6 +210,7 @@ export function ConfigSection({
[onPendingDataChange, sectionPath, cameraName],
);
const [isSaving, setIsSaving] = useState(false);
const [extraHasChanges, setExtraHasChanges] = useState(false);
const [formKey, setFormKey] = useState(0);
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
@ -356,9 +362,11 @@ export function ConfigSection({
// Track if there are unsaved changes
const hasChanges = useMemo(() => {
if (!pendingData) return false;
return !isEqual(formData, pendingData);
}, [formData, pendingData]);
const pendingChanged = pendingData
? !isEqual(formData, pendingData)
: false;
return pendingChanged || extraHasChanges;
}, [formData, pendingData, extraHasChanges]);
useEffect(() => {
onStatusChange?.({ hasChanges, isOverridden });
@ -462,6 +470,7 @@ export function ConfigSection({
setPendingData(null);
setPendingOverrides(undefined);
setDirtyOverrides(undefined);
setExtraHasChanges(false);
setFormKey((prev) => prev + 1);
}, [setPendingData, setPendingOverrides, setDirtyOverrides]);
@ -649,6 +658,7 @@ export function ConfigSection({
);
setPendingData(null);
setExtraHasChanges(false);
refreshConfig();
} catch {
toast.error(
@ -714,10 +724,11 @@ export function ConfigSection({
selectedCamera={cameraName}
setUnsavedChanges={(hasChanges: boolean) => {
// Translate setUnsavedChanges to pending data state
if (hasChanges && !pendingData) {
const currentPending = pendingDataRef.current;
if (hasChanges && !currentPending) {
// Component signaled changes but we don't have pending data yet
// This can happen when the component manages its own state
} else if (!hasChanges && pendingData) {
} else if (!hasChanges && currentPending) {
// Component signaled no changes, clear pending
setPendingData(null);
}
@ -726,13 +737,7 @@ export function ConfigSection({
),
]),
);
}, [
sectionConfig?.renderers,
sectionPath,
cameraName,
pendingData,
setPendingData,
]);
}, [sectionConfig?.renderers, sectionPath, cameraName, setPendingData]);
if (!modifiedSchema) {
return null;
@ -787,9 +792,13 @@ export function ConfigSection({
globalValue,
cameraValue,
hasChanges,
extraHasChanges,
setExtraHasChanges,
overrides: uiOverrides as JsonValue | undefined,
formData: currentFormData as ConfigSectionData,
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
pendingDataBySection,
onPendingDataChange,
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
@ -811,7 +820,6 @@ export function ConfigSection({
}}
/>
{/* Save button */}
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pb-5 pt-0">
<div
className={cn(
@ -874,7 +882,7 @@ export function ConfigSection({
})}
</>
) : (
<>{t("button.save", { ns: "common", defaultValue: "Save" })}</>
t("button.save", { ns: "common", defaultValue: "Save" })
)}
</Button>
</div>

View File

@ -38,7 +38,6 @@ import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import UsersView from "@/views/settings/UsersView";
import RolesView from "@/views/settings/RolesView";
import NotificationView from "@/views/settings/NotificationsSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import MaintenanceSettingsView from "@/views/settings/MaintenanceSettingsView";
@ -238,6 +237,7 @@ const SystemDetectorHardwareSettingsPage = createSectionPage(
"global",
);
const SystemDetectionModelSettingsPage = createSectionPage("model", "global");
const NotificationsSettingsPage = createSectionPage("notifications", "global");
const SystemMqttSettingsPage = createSectionPage("mqtt", "global");
const IntegrationSemanticSearchSettingsPage = createSectionPage(
@ -432,7 +432,7 @@ const settingsGroups = [
},
{
label: "notifications",
items: [{ key: "notifications", component: NotificationView }],
items: [{ key: "notifications", component: NotificationsSettingsPage }],
},
{
label: "frigateplus",
@ -513,6 +513,7 @@ const GLOBAL_SECTION_MAPPING: Record<string, SettingsType> = {
audio: "globalAudioEvents",
live: "globalLivePlayback",
timestamp_style: "globalTimestampStyle",
notifications: "notifications",
};
const ENRICHMENTS_SECTION_MAPPING: Record<string, SettingsType> = {

View File

@ -20,7 +20,15 @@ export type ConfigFormContext = {
cameraValue?: JsonValue;
overrides?: JsonValue;
hasChanges?: boolean;
extraHasChanges?: boolean;
setExtraHasChanges?: (hasChanges: boolean) => void;
formData?: JsonObject;
pendingDataBySection?: Record<string, unknown>;
onPendingDataChange?: (
sectionKey: string,
cameraName: string | undefined,
data: ConfigSectionData | null,
) => void;
baselineFormData?: JsonObject;
hiddenFields?: string[];
onFormDataChange?: (data: ConfigSectionData) => void;