// Base Section Component for config form sections // Used as a foundation for reusable section components import { useMemo, useCallback, useState, useEffect, useRef } from "react"; import useSWR from "swr"; import axios from "axios"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import sectionRenderers, { RendererComponent, } from "@/components/config-form/sectionExtras/registry"; import { ConfigForm } from "../ConfigForm"; import type { FormValidation, UiSchema } from "@rjsf/utils"; import { modifySchemaForSection, getEffectiveDefaultsForSection, sanitizeOverridesForSection, } from "./section-special-cases"; import { getSectionValidation } from "../section-validations"; import { useConfigOverride } from "@/hooks/use-config-override"; import { useSectionSchema } from "@/hooks/use-config-schema"; import type { FrigateConfig } from "@/types/frigateConfig"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import Heading from "@/components/ui/heading"; import get from "lodash/get"; import cloneDeep from "lodash/cloneDeep"; import isEqual from "lodash/isEqual"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { applySchemaDefaults } from "@/lib/config-schema"; import { cn } from "@/lib/utils"; import { ConfigSectionData, JsonValue } from "@/types/configForm"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { cameraUpdateTopicMap, buildOverrides, sanitizeSectionData as sharedSanitizeSectionData, requiresRestartForOverrides as sharedRequiresRestartForOverrides, } from "@/utils/configSaveUtil"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import { useRestart } from "@/api/ws"; export interface SectionConfig { /** Field ordering within the section */ fieldOrder?: string[]; /** Fields to group together */ fieldGroups?: Record; /** Fields to hide from UI */ hiddenFields?: string[]; /** Fields to show in advanced section */ advancedFields?: string[]; /** Fields to compare for override detection */ overrideFields?: string[]; /** Documentation link for the section */ sectionDocs?: string; /** Per-field documentation links */ fieldDocs?: Record; /** Fields that require restart when modified (empty means none; undefined uses default) */ restartRequired?: string[]; /** Whether to enable live validation */ liveValidate?: boolean; /** Additional uiSchema overrides */ uiSchema?: UiSchema; /** Optional per-section renderers usable by FieldTemplate `ui:before`/`ui:after` */ renderers?: Record; /** Optional custom validation for section data */ customValidate?: ( formData: unknown, errors: FormValidation, ) => FormValidation; } export interface BaseSectionProps { /** Whether this is at global or camera level */ level: "global" | "camera"; /** Camera name (required if level is "camera") */ cameraName?: string; /** Whether to show override indicator badge */ showOverrideIndicator?: boolean; /** Custom section configuration */ sectionConfig?: SectionConfig; /** Whether the section is disabled */ disabled?: boolean; /** Whether the section is read-only */ readonly?: boolean; /** Callback when settings are saved */ onSave?: () => void; /** Whether a restart is required after changes */ requiresRestart?: boolean; /** Whether section is collapsible */ collapsible?: boolean; /** Default collapsed state */ defaultCollapsed?: boolean; /** Whether to show the section title (default: false for global, true for camera) */ showTitle?: boolean; /** Callback when section status changes */ onStatusChange?: (status: { hasChanges: boolean; isOverridden: boolean; }) => void; /** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */ pendingDataBySection?: Record; /** Callback to update pending data for a section */ onPendingDataChange?: ( sectionKey: string, cameraName: string | undefined, data: ConfigSectionData | null, ) => void; } export interface CreateSectionOptions { /** The config path for this section (e.g., "detect", "record") */ sectionPath: string; /** Default section configuration */ defaultConfig: SectionConfig; } export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions; export function ConfigSection({ sectionPath, defaultConfig, level, cameraName, showOverrideIndicator = true, sectionConfig = defaultConfig, disabled = false, readonly = false, onSave, requiresRestart = true, collapsible = false, defaultCollapsed = true, showTitle, onStatusChange, pendingDataBySection, onPendingDataChange, }: ConfigSectionProps) { const { t, i18n } = useTranslation([ level === "camera" ? "config/cameras" : "config/global", "config/cameras", "views/settings", "common", "components/dialog", ]); const [isOpen, setIsOpen] = useState(!defaultCollapsed); const { send: sendRestart } = useRestart(); // Create a key for this section's pending data const pendingDataKey = useMemo( () => level === "camera" && cameraName ? `${cameraName}::${sectionPath}` : sectionPath, [level, cameraName, sectionPath], ); // Use pending data from parent if available, otherwise use local state const [localPendingData, setLocalPendingData] = useState(null); const [pendingOverrides, setPendingOverrides] = useState< JsonValue | undefined >(undefined); const [dirtyOverrides, setDirtyOverrides] = useState( undefined, ); const [baselineFormData, setBaselineFormData] = useState(null); const pendingData = pendingDataBySection !== undefined ? (pendingDataBySection[pendingDataKey] as ConfigSectionData | null) : localPendingData; const setPendingData = useCallback( (data: ConfigSectionData | null) => { if (onPendingDataChange) { onPendingDataChange(sectionPath, cameraName, data); } else { setLocalPendingData(data); } }, [onPendingDataChange, sectionPath, cameraName], ); const [isSaving, setIsSaving] = useState(false); const [formKey, setFormKey] = useState(0); const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false); const isResettingRef = useRef(false); const isInitializingRef = useRef(true); const updateTopic = level === "camera" && cameraName ? cameraUpdateTopicMap[sectionPath] ? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}` : undefined : `config/${sectionPath}`; // Default: show title for camera level (since it might be collapsible), hide for global const shouldShowTitle = showTitle ?? level === "camera"; // Fetch config const { data: config, mutate: refreshConfig } = useSWR("config"); // Get section schema using cached hook const sectionSchema = useSectionSchema(sectionPath, level); // Apply special case handling for sections with problematic schema defaults const modifiedSchema = useMemo( () => modifySchemaForSection(sectionPath, level, sectionSchema ?? undefined), [sectionPath, level, sectionSchema], ); // Get override status const { isOverridden, globalValue, cameraValue } = useConfigOverride({ config, cameraName: level === "camera" ? cameraName : undefined, sectionPath, compareFields: sectionConfig.overrideFields, }); // Get current form data const rawSectionValue = useMemo(() => { if (!config) return undefined; if (level === "camera" && cameraName) { return get(config.cameras?.[cameraName], sectionPath); } return get(config, sectionPath); }, [config, level, cameraName, sectionPath]); const rawFormData = useMemo(() => { if (!config) return {}; if (rawSectionValue === undefined || rawSectionValue === null) { return {}; } return rawSectionValue; }, [config, rawSectionValue]); const sanitizeSectionData = useCallback( (data: ConfigSectionData) => sharedSanitizeSectionData(data, sectionConfig.hiddenFields), [sectionConfig.hiddenFields], ); const formData = useMemo(() => { const baseData = modifiedSchema ? applySchemaDefaults(modifiedSchema, rawFormData) : rawFormData; return sanitizeSectionData(baseData); }, [rawFormData, modifiedSchema, sanitizeSectionData]); const schemaDefaults = useMemo(() => { if (!modifiedSchema) { return {}; } return applySchemaDefaults(modifiedSchema, {}); }, [modifiedSchema]); // Get effective defaults, handling special cases where schema defaults // don't match semantic intent const effectiveSchemaDefaults = useMemo( () => getEffectiveDefaultsForSection( sectionPath, level, modifiedSchema, schemaDefaults, ), [level, schemaDefaults, sectionPath, modifiedSchema], ); const compareBaseData = useMemo( () => sanitizeSectionData(rawFormData as ConfigSectionData), [rawFormData, sanitizeSectionData], ); // Clear pendingData whenever formData changes (e.g., from server refresh) // This prevents RJSF's initial onChange call from being treated as a user edit // Only clear if pendingData is managed locally (not by parent) useEffect(() => { if (!pendingData) { isInitializingRef.current = true; setPendingOverrides(undefined); setDirtyOverrides(undefined); setBaselineFormData(cloneDeep(formData as ConfigSectionData)); } if (onPendingDataChange === undefined) { setPendingData(null); } }, [ formData, pendingData, setPendingData, setBaselineFormData, onPendingDataChange, ]); useEffect(() => { if (isResettingRef.current) { isResettingRef.current = false; } }, [formKey]); // Track if there are unsaved changes const hasChanges = useMemo(() => { if (!pendingData) return false; return !isEqual(formData, pendingData); }, [formData, pendingData]); useEffect(() => { onStatusChange?.({ hasChanges, isOverridden }); }, [hasChanges, isOverridden, onStatusChange]); // Handle form data change const handleChange = useCallback( (data: unknown) => { if (isResettingRef.current) { setPendingData(null); setPendingOverrides(undefined); return; } if (!data || typeof data !== "object") { setPendingData(null); setPendingOverrides(undefined); return; } const sanitizedData = sanitizeSectionData(data as ConfigSectionData); let nextBaselineFormData = baselineFormData ?? formData; const overrides = buildOverrides( sanitizedData, compareBaseData, effectiveSchemaDefaults, ); setPendingOverrides(overrides as JsonValue | undefined); if (isInitializingRef.current && !pendingData) { isInitializingRef.current = false; if (!baselineFormData) { // Always use formData (server data + schema defaults) for the // baseline snapshot, NOT sanitizedData from the onChange callback. // If a custom component (e.g., zone checkboxes) triggers onChange // before RJSF's initial onChange, sanitizedData would include the // user's modification, corrupting the baseline. const baselineSnapshot = cloneDeep(formData as ConfigSectionData); setBaselineFormData(baselineSnapshot); nextBaselineFormData = baselineSnapshot; } if (overrides === undefined) { setPendingData(null); setPendingOverrides(undefined); setDirtyOverrides(undefined); return; } } const dirty = buildOverrides( sanitizedData, nextBaselineFormData, undefined, ); setDirtyOverrides(dirty as JsonValue | undefined); if (overrides === undefined) { setPendingData(null); setPendingOverrides(undefined); setDirtyOverrides(undefined); return; } setPendingData(sanitizedData); }, [ pendingData, compareBaseData, sanitizeSectionData, effectiveSchemaDefaults, setPendingData, setPendingOverrides, setDirtyOverrides, baselineFormData, setBaselineFormData, formData, ], ); const currentFormData = pendingData || formData; const effectiveBaselineFormData = baselineFormData ?? formData; const currentOverrides = useMemo(() => { if (!currentFormData || typeof currentFormData !== "object") { return undefined; } const sanitizedData = sanitizeSectionData( currentFormData as ConfigSectionData, ); return buildOverrides( sanitizedData, compareBaseData, effectiveSchemaDefaults, ); }, [ currentFormData, sanitizeSectionData, compareBaseData, effectiveSchemaDefaults, ]); const effectiveOverrides = pendingData ? (pendingOverrides ?? currentOverrides) : undefined; const uiOverrides = dirtyOverrides ?? effectiveOverrides; const requiresRestartForOverrides = useCallback( (overrides: unknown) => sharedRequiresRestartForOverrides( overrides, sectionConfig.restartRequired, requiresRestart, ), [requiresRestart, sectionConfig.restartRequired], ); const handleReset = useCallback(() => { isResettingRef.current = true; setPendingData(null); setPendingOverrides(undefined); setDirtyOverrides(undefined); setFormKey((prev) => prev + 1); }, [setPendingData, setPendingOverrides, setDirtyOverrides]); // Handle save button click const handleSave = useCallback(async () => { if (!pendingData) return; setIsSaving(true); try { const basePath = level === "camera" && cameraName ? `cameras.${cameraName}.${sectionPath}` : sectionPath; const rawData = sanitizeSectionData(rawFormData); const overrides = buildOverrides( pendingData, rawData, effectiveSchemaDefaults, ); const sanitizedOverrides = sanitizeOverridesForSection( sectionPath, level, overrides, ); if ( !sanitizedOverrides || typeof sanitizedOverrides !== "object" || Object.keys(sanitizedOverrides).length === 0 ) { setPendingData(null); return; } const needsRestart = requiresRestartForOverrides(sanitizedOverrides); await axios.put("config/set", { requires_restart: needsRestart ? 1 : 0, update_topic: updateTopic, config_data: { [basePath]: sanitizedOverrides, }, }); // log save to console for debugging // eslint-disable-next-line no-console console.log("Saved config data:", { [basePath]: sanitizedOverrides, update_topic: updateTopic, requires_restart: needsRestart ? 1 : 0, }); if (needsRestart) { toast.success( t("toast.successRestartRequired", { ns: "views/settings", defaultValue: "Settings saved successfully. Restart Frigate to apply your changes.", }), { action: ( setRestartDialogOpen(true)}> ), }, ); } else { toast.success( t("toast.success", { ns: "views/settings", defaultValue: "Settings saved successfully", }), ); } setPendingData(null); refreshConfig(); onSave?.(); } catch (error) { // Parse Pydantic validation errors from API response if (axios.isAxiosError(error) && error.response?.data) { const responseData = error.response.data; if (responseData.detail && Array.isArray(responseData.detail)) { const validationMessages = responseData.detail .map((err: { loc?: string[]; msg?: string }) => { const field = err.loc?.slice(1).join(".") || "unknown"; return `${field}: ${err.msg || "Invalid value"}`; }) .join(", "); toast.error( t("toast.validationError", { ns: "views/settings", defaultValue: `Validation failed: ${validationMessages}`, }), ); } else if (responseData.message) { toast.error(responseData.message); } else { toast.error( t("toast.error", { ns: "views/settings", defaultValue: "Failed to save settings", }), ); } } else { toast.error( t("toast.error", { ns: "views/settings", defaultValue: "Failed to save settings", }), ); } } finally { setIsSaving(false); } }, [ sectionPath, pendingData, level, cameraName, t, refreshConfig, onSave, rawFormData, sanitizeSectionData, effectiveSchemaDefaults, updateTopic, setPendingData, requiresRestartForOverrides, ]); // Handle reset to global/defaults - removes camera-level override or resets global to defaults const handleResetToGlobal = useCallback(async () => { if (level === "camera" && !cameraName) return; try { const basePath = level === "camera" && cameraName ? `cameras.${cameraName}.${sectionPath}` : sectionPath; const configData = ""; await axios.put("config/set", { requires_restart: requiresRestart ? 0 : 1, update_topic: updateTopic, config_data: { [basePath]: configData, }, }); // log reset to console for debugging // eslint-disable-next-line no-console console.log( level === "global" ? "Reset to defaults for path:" : "Reset to global config for path:", basePath, { update_topic: updateTopic, requires_restart: requiresRestart ? 0 : 1, }, ); toast.success( t("toast.resetSuccess", { ns: "views/settings", defaultValue: level === "global" ? "Reset to defaults" : "Reset to global defaults", }), ); setPendingData(null); refreshConfig(); } catch { toast.error( t("toast.resetError", { ns: "views/settings", defaultValue: "Failed to reset settings", }), ); } }, [ sectionPath, level, cameraName, requiresRestart, t, refreshConfig, updateTopic, setPendingData, ]); const sectionValidation = useMemo( () => getSectionValidation({ sectionPath, level, t }), [sectionPath, level, t], ); const customValidate = useMemo(() => { const validators: Array< (formData: unknown, errors: FormValidation) => FormValidation > = []; if (sectionConfig.customValidate) { validators.push(sectionConfig.customValidate); } if (sectionValidation) { validators.push(sectionValidation); } if (validators.length === 0) { return undefined; } return (formData: unknown, errors: FormValidation) => validators.reduce( (currentErrors, validatorFn) => validatorFn(formData, currentErrors), errors, ); }, [sectionConfig.customValidate, sectionValidation]); // Wrap renderers with runtime props (selectedCamera, setUnsavedChanges, etc.) const wrappedRenderers = useMemo(() => { const baseRenderers = sectionConfig?.renderers ?? sectionRenderers?.[sectionPath]; if (!baseRenderers) return undefined; // Create wrapper that injects runtime props return Object.fromEntries( Object.entries(baseRenderers).map(([key, RendererComponent]) => [ key, (staticProps: Record = {}) => ( { // Translate setUnsavedChanges to pending data state if (hasChanges && !pendingData) { // 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) { // Component signaled no changes, clear pending setPendingData(null); } }} /> ), ]), ); }, [ sectionConfig?.renderers, sectionPath, cameraName, pendingData, setPendingData, ]); if (!modifiedSchema) { return null; } // Get section title from config namespace const defaultTitle = sectionPath.charAt(0).toUpperCase() + sectionPath.slice(1).replace(/_/g, " "); // For camera-level sections, keys live under `config/cameras` and are // nested under the section name (e.g., `audio.label`). For global-level // sections, keys are nested under the section name in `config/global`. const configNamespace = level === "camera" ? "config/cameras" : "config/global"; const title = t(`${sectionPath}.label`, { ns: configNamespace, defaultValue: defaultTitle, }); const sectionDescription = i18n.exists(`${sectionPath}.description`, { ns: configNamespace, }) ? t(`${sectionPath}.description`, { ns: configNamespace }) : undefined; if (!sectionSchema || !config) { return ; } const sectionContent = (
handleChange(data), // For widgets that need access to full camera config (e.g., zone names) fullCameraConfig: level === "camera" && cameraName ? config?.cameras?.[cameraName] : undefined, fullConfig: config, // When rendering camera-level sections, provide the section path so // field templates can look up keys under the `config/cameras` namespace // When using a consolidated global namespace, keys are nested // under the section name (e.g., `audio.label`) so provide the // section prefix to templates so they can attempt `${section}.${field}` lookups. sectionI18nPrefix: sectionPath, t, renderers: wrappedRenderers, sectionDocs: sectionConfig.sectionDocs, fieldDocs: sectionConfig.fieldDocs, hiddenFields: sectionConfig.hiddenFields, }} /> {/* Save button */}
{hasChanges && (
{t("unsavedChanges", { ns: "views/settings", defaultValue: "You have unsaved changes", })}
)}
{((level === "camera" && isOverridden) || level === "global") && !hasChanges && ( )} {hasChanges && ( )}
{t("confirmReset", { ns: "views/settings" })} {level === "global" ? t("resetToDefaultDescription", { ns: "views/settings" }) : t("resetToGlobalDescription", { ns: "views/settings" })} {t("button.cancel", { ns: "common" })} { await handleResetToGlobal(); setIsResetDialogOpen(false); }} > {level === "global" ? t("button.resetToDefault", { ns: "common" }) : t("button.resetToGlobal", { ns: "common" })}
); if (collapsible) { return ( <>
{isOpen ? ( ) : ( )} {title} {showOverrideIndicator && level === "camera" && isOverridden && ( {t("button.overridden", { ns: "common", defaultValue: "Overridden", })} )} {hasChanges && ( {t("modified", { ns: "common", defaultValue: "Modified", })} )}
{sectionContent}
setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} /> ); } return ( <>
{shouldShowTitle && (
{title} {showOverrideIndicator && level === "camera" && isOverridden && ( {t("button.overridden", { ns: "common", defaultValue: "Overridden", })} )} {hasChanges && ( {t("modified", { ns: "common", defaultValue: "Modified" })} )}
{sectionDescription && (

{sectionDescription}

)}
)} {sectionContent}
setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} /> ); }