frigate/web/src/components/config-form/sections/BaseSection.tsx

478 lines
15 KiB
TypeScript
Raw Normal View History

// Base Section Component for config form sections
// Used as a foundation for reusable section components
2026-01-23 19:04:50 +03:00
import { useMemo, useCallback, useState } from "react";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { ConfigForm } from "../ConfigForm";
2026-01-24 18:43:13 +03:00
import {
useConfigOverride,
normalizeConfigValue,
} 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";
2026-01-23 19:04:50 +03:00
import {
LuRotateCcw,
LuSave,
LuChevronDown,
LuChevronRight,
} from "react-icons/lu";
import Heading from "@/components/ui/heading";
import get from "lodash/get";
2026-01-24 18:43:13 +03:00
import unset from "lodash/unset";
import cloneDeep from "lodash/cloneDeep";
2026-01-23 19:04:50 +03:00
import isEqual from "lodash/isEqual";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
2026-01-24 18:43:13 +03:00
import { applySchemaDefaults } from "@/lib/config-schema";
export interface SectionConfig {
/** Field ordering within the section */
fieldOrder?: string[];
/** Fields to group together */
fieldGroups?: Record<string, string[]>;
/** Fields to hide from UI */
hiddenFields?: string[];
/** Fields to show in advanced section */
advancedFields?: string[];
}
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;
2026-01-23 19:04:50 +03:00
/** 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;
}
export interface CreateSectionOptions {
/** The config path for this section (e.g., "detect", "record") */
sectionPath: string;
2026-01-23 19:04:50 +03:00
/** i18n namespace for this section (e.g., "config/detect") */
i18nNamespace: string;
/** Default section configuration */
defaultConfig: SectionConfig;
}
/**
* Factory function to create reusable config section components
*/
export function createConfigSection({
sectionPath,
2026-01-23 19:04:50 +03:00
i18nNamespace,
defaultConfig,
}: CreateSectionOptions) {
2026-01-23 19:04:50 +03:00
const ConfigSection = function ConfigSection({
level,
cameraName,
showOverrideIndicator = true,
sectionConfig = defaultConfig,
disabled = false,
readonly = false,
onSave,
requiresRestart = true,
2026-01-23 19:04:50 +03:00
collapsible = false,
defaultCollapsed = false,
showTitle,
}: BaseSectionProps) {
2026-01-23 19:04:50 +03:00
const { t } = useTranslation([i18nNamespace, "views/settings", "common"]);
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
const [pendingData, setPendingData] = useState<Record<
string,
unknown
> | null>(null);
const [isSaving, setIsSaving] = useState(false);
// 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<FrigateConfig>("config");
// Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, level);
// Get override status
2026-01-23 19:04:50 +03:00
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
config,
cameraName: level === "camera" ? cameraName : undefined,
sectionPath,
});
// Get current form data
2026-01-24 18:43:13 +03:00
const rawFormData = useMemo(() => {
if (!config) return {};
if (level === "camera" && cameraName) {
return get(config.cameras?.[cameraName], sectionPath) || {};
}
return get(config, sectionPath) || {};
}, [config, level, cameraName]);
2026-01-24 18:43:13 +03:00
const sanitizeSectionData = useCallback(
(data: Record<string, unknown>) => {
const normalized = normalizeConfigValue(data) as Record<
string,
unknown
>;
if (
!sectionConfig.hiddenFields ||
sectionConfig.hiddenFields.length === 0
) {
return normalized;
}
const cleaned = cloneDeep(normalized);
sectionConfig.hiddenFields.forEach((path) => {
if (!path) return;
unset(cleaned, path);
});
return cleaned;
},
[sectionConfig.hiddenFields],
);
const formData = useMemo(() => {
const baseData = sectionSchema
? applySchemaDefaults(sectionSchema, rawFormData)
: rawFormData;
return sanitizeSectionData(baseData);
}, [rawFormData, sectionSchema, sanitizeSectionData]);
2026-01-23 19:04:50 +03:00
// Track if there are unsaved changes
const hasChanges = useMemo(() => {
if (!pendingData) return false;
return !isEqual(formData, pendingData);
}, [formData, pendingData]);
// Handle form data change
2026-01-24 18:43:13 +03:00
const handleChange = useCallback(
(data: Record<string, unknown>) => {
const sanitizedData = sanitizeSectionData(data);
if (isEqual(formData, sanitizedData)) {
setPendingData(null);
return;
}
setPendingData(sanitizedData);
},
[formData, sanitizeSectionData],
);
2026-01-23 19:04:50 +03:00
// Handle save button click
const handleSave = useCallback(async () => {
if (!pendingData) return;
2026-01-23 19:04:50 +03:00
setIsSaving(true);
try {
const basePath =
level === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
await axios.put("config/set", {
requires_restart: requiresRestart ? 1 : 0,
config_data: {
[basePath]: pendingData,
},
});
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(
2026-01-23 19:04:50 +03:00
t("toast.error", {
ns: "views/settings",
defaultValue: "Failed to save settings",
}),
);
}
2026-01-23 19:04:50 +03:00
} else {
toast.error(
t("toast.error", {
ns: "views/settings",
defaultValue: "Failed to save settings",
}),
);
}
2026-01-23 19:04:50 +03:00
} finally {
setIsSaving(false);
}
}, [
pendingData,
level,
cameraName,
requiresRestart,
t,
refreshConfig,
onSave,
]);
2026-01-23 19:04:50 +03:00
// Handle reset to global - removes camera-level override by deleting the section
const handleResetToGlobal = useCallback(async () => {
if (level !== "camera" || !cameraName) return;
try {
const basePath = `cameras.${cameraName}.${sectionPath}`;
2026-01-23 19:04:50 +03:00
// Send empty string to delete the key from config (see update_yaml in backend)
await axios.put("config/set", {
requires_restart: requiresRestart ? 1 : 0,
config_data: {
2026-01-23 19:04:50 +03:00
[basePath]: "",
},
});
toast.success(
2026-01-23 19:04:50 +03:00
t("toast.resetSuccess", {
ns: "views/settings",
defaultValue: "Reset to global defaults",
}),
);
2026-01-23 19:04:50 +03:00
setPendingData(null);
refreshConfig();
} catch {
toast.error(
2026-01-23 19:04:50 +03:00
t("toast.resetError", {
ns: "views/settings",
defaultValue: "Failed to reset settings",
}),
);
}
2026-01-23 19:04:50 +03:00
}, [level, cameraName, requiresRestart, t, refreshConfig]);
if (!sectionSchema) {
return null;
}
2026-01-23 19:04:50 +03:00
// Get section title from config namespace
const title = t("label", {
ns: i18nNamespace,
defaultValue:
sectionPath.charAt(0).toUpperCase() +
sectionPath.slice(1).replace(/_/g, " "),
});
2026-01-23 19:04:50 +03:00
const sectionContent = (
<div className="space-y-6">
<ConfigForm
schema={sectionSchema}
formData={pendingData || formData}
onChange={handleChange}
fieldOrder={sectionConfig.fieldOrder}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
disabled={disabled || isSaving}
readonly={readonly}
showSubmit={false}
i18nNamespace={i18nNamespace}
formContext={{
level,
cameraName,
globalValue,
cameraValue,
}}
/>
{/* Save button */}
<div className="flex items-center justify-between pt-2">
<div className="flex items-center gap-2">
2026-01-23 19:04:50 +03:00
{hasChanges && (
<span className="text-sm text-muted-foreground">
{t("unsavedChanges", {
ns: "views/settings",
defaultValue: "You have unsaved changes",
})}
</span>
)}
</div>
<Button
onClick={handleSave}
disabled={!hasChanges || isSaving || disabled}
className="gap-2"
>
<LuSave className="h-4 w-4" />
{isSaving
? t("saving", { ns: "common", defaultValue: "Saving..." })
: t("save", { ns: "common", defaultValue: "Save" })}
</Button>
</div>
</div>
);
if (collapsible) {
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="space-y-3">
<CollapsibleTrigger asChild>
<div className="flex cursor-pointer items-center justify-between">
<div className="flex items-center gap-3">
{isOpen ? (
<LuChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<LuChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Heading as="h4">{title}</Heading>
{showOverrideIndicator &&
level === "camera" &&
isOverridden && (
<Badge variant="secondary" className="text-xs">
{t("overridden", {
ns: "common",
defaultValue: "Overridden",
})}
</Badge>
)}
{hasChanges && (
<Badge variant="outline" className="text-xs">
{t("modified", {
ns: "common",
defaultValue: "Modified",
})}
</Badge>
)}
</div>
{level === "camera" && isOverridden && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleResetToGlobal();
}}
className="gap-2"
>
<LuRotateCcw className="h-4 w-4" />
{t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pl-7">{sectionContent}</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
return (
<div className="space-y-3">
{shouldShowTitle && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Heading as="h4">{title}</Heading>
{showOverrideIndicator && level === "camera" && isOverridden && (
<Badge variant="secondary" className="text-xs">
{t("overridden", {
ns: "common",
defaultValue: "Overridden",
})}
</Badge>
)}
{hasChanges && (
<Badge variant="outline" className="text-xs">
{t("modified", { ns: "common", defaultValue: "Modified" })}
</Badge>
)}
</div>
{level === "camera" && isOverridden && (
<Button
variant="ghost"
size="sm"
onClick={handleResetToGlobal}
className="gap-2"
>
<LuRotateCcw className="h-4 w-4" />
{t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
)}
</div>
2026-01-23 19:04:50 +03:00
)}
{/* Reset button when title is hidden but we're at camera level with override */}
{!shouldShowTitle && level === "camera" && isOverridden && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={handleResetToGlobal}
className="gap-2"
>
<LuRotateCcw className="h-4 w-4" />
2026-01-23 19:04:50 +03:00
{t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
2026-01-23 19:04:50 +03:00
</div>
)}
{sectionContent}
</div>
);
};
2026-01-23 19:04:50 +03:00
return ConfigSection;
}