diff --git a/web/public/locales/en/config/validation.json b/web/public/locales/en/config/validation.json index 6684107ac..af0599733 100644 --- a/web/public/locales/en/config/validation.json +++ b/web/public/locales/en/config/validation.json @@ -16,5 +16,6 @@ "format": "Invalid format", "additionalProperties": "Unknown property is not allowed", "oneOf": "Must match exactly one of the allowed schemas", - "anyOf": "Must match at least one of the allowed schemas" + "anyOf": "Must match at least one of the allowed schemas", + "ffmpeg.inputs.rolesUnique": "Each role can only be assigned to one input stream." } diff --git a/web/src/components/config-form/ConfigForm.tsx b/web/src/components/config-form/ConfigForm.tsx index b457666bb..b0ae75014 100644 --- a/web/src/components/config-form/ConfigForm.tsx +++ b/web/src/components/config-form/ConfigForm.tsx @@ -1,7 +1,7 @@ // ConfigForm - Main RJSF form wrapper component import Form from "@rjsf/shadcn"; import validator from "@rjsf/validator-ajv8"; -import type { RJSFSchema, UiSchema } from "@rjsf/utils"; +import type { FormValidation, RJSFSchema, UiSchema } from "@rjsf/utils"; import type { IChangeEvent } from "@rjsf/core"; import { frigateTheme } from "./theme"; import { transformSchema } from "@/lib/config-schema"; @@ -182,6 +182,11 @@ export interface ConfigFormProps { formContext?: ConfigFormContext; /** i18n namespace for field labels */ i18nNamespace?: string; + /** Optional custom validation */ + customValidate?: ( + formData: unknown, + errors: FormValidation, + ) => FormValidation; } export function ConfigForm({ @@ -202,6 +207,7 @@ export function ConfigForm({ liveValidate = true, formContext, i18nNamespace, + customValidate, }: ConfigFormProps) { const { t, i18n } = useTranslation([ i18nNamespace || "common", @@ -319,6 +325,7 @@ export function ConfigForm({ liveValidate={liveValidate} formContext={extendedFormContext} transformErrors={errorTransformer} + customValidate={customValidate} {...frigateTheme} /> diff --git a/web/src/components/config-form/section-validations/ffmpeg.ts b/web/src/components/config-form/section-validations/ffmpeg.ts new file mode 100644 index 000000000..4fa28700a --- /dev/null +++ b/web/src/components/config-form/section-validations/ffmpeg.ts @@ -0,0 +1,47 @@ +import type { FormValidation } from "@rjsf/utils"; +import type { TFunction } from "i18next"; +import { isJsonObject } from "@/lib/utils"; +import type { JsonObject } from "@/types/configForm"; + +export function validateFfmpegInputRoles( + formData: unknown, + errors: FormValidation, + t: TFunction, +): FormValidation { + if (!isJsonObject(formData as JsonObject)) { + return errors; + } + + const inputs = (formData as JsonObject).inputs; + if (!Array.isArray(inputs)) { + return errors; + } + + const roleCounts = new Map(); + inputs.forEach((input) => { + if (!isJsonObject(input) || !Array.isArray(input.roles)) { + return; + } + input.roles.forEach((role) => { + if (typeof role !== "string") { + return; + } + roleCounts.set(role, (roleCounts.get(role) || 0) + 1); + }); + }); + + const hasDuplicates = Array.from(roleCounts.values()).some( + (count) => count > 1, + ); + + if (hasDuplicates) { + const inputsErrors = errors.inputs as { + addError?: (message: string) => void; + }; + inputsErrors?.addError?.( + t("ffmpeg.inputs.rolesUnique", { ns: "config/validation" }), + ); + } + + return errors; +} diff --git a/web/src/components/config-form/section-validations/index.ts b/web/src/components/config-form/section-validations/index.ts new file mode 100644 index 000000000..1be445648 --- /dev/null +++ b/web/src/components/config-form/section-validations/index.ts @@ -0,0 +1,26 @@ +import type { FormValidation } from "@rjsf/utils"; +import type { TFunction } from "i18next"; +import { validateFfmpegInputRoles } from "./ffmpeg"; + +export type SectionValidation = ( + formData: unknown, + errors: FormValidation, +) => FormValidation; + +type SectionValidationOptions = { + sectionPath: string; + level: "global" | "camera"; + t: TFunction; +}; + +export function getSectionValidation({ + sectionPath, + level, + t, +}: SectionValidationOptions): SectionValidation | undefined { + if (sectionPath === "ffmpeg" && level === "camera") { + return (formData, errors) => validateFfmpegInputRoles(formData, errors, t); + } + + return undefined; +} diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 36fa07d4c..d83cc35f2 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -10,7 +10,8 @@ import sectionRenderers, { RendererComponent, } from "@/components/config-form/sectionExtras/registry"; import { ConfigForm } from "../ConfigForm"; -import type { UiSchema } from "@rjsf/utils"; +import type { FormValidation, UiSchema } from "@rjsf/utils"; +import { getSectionValidation } from "../section-validations"; import { useConfigOverride, normalizeConfigValue, @@ -56,6 +57,11 @@ export interface SectionConfig { 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 { @@ -90,313 +96,299 @@ export interface CreateSectionOptions { defaultConfig: SectionConfig; } -/** - * Factory function to create reusable config section components - */ -export function createConfigSection({ +const cameraUpdateTopicMap: Record = { + detect: "detect", + record: "record", + snapshots: "snapshots", + motion: "motion", + objects: "objects", + review: "review", + audio: "audio", + notifications: "notifications", + live: "live", + timestamp_style: "timestamp_style", + audio_transcription: "audio_transcription", + birdseye: "birdseye", + face_recognition: "face_recognition", + ffmpeg: "ffmpeg", + lpr: "lpr", + semantic_search: "semantic_search", + mqtt: "mqtt", + onvif: "onvif", + ui: "ui", +}; + +export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions; + +export function ConfigSection({ sectionPath, defaultConfig, -}: CreateSectionOptions) { - const cameraUpdateTopicMap: Record = { - detect: "detect", - record: "record", - snapshots: "snapshots", - motion: "motion", - objects: "objects", - review: "review", - audio: "audio", - notifications: "notifications", - live: "live", - timestamp_style: "timestamp_style", - audio_transcription: "audio_transcription", - birdseye: "birdseye", - face_recognition: "face_recognition", - ffmpeg: "ffmpeg", - lpr: "lpr", - semantic_search: "semantic_search", - mqtt: "mqtt", - onvif: "onvif", - ui: "ui", - }; + level, + cameraName, + showOverrideIndicator = true, + sectionConfig = defaultConfig, + disabled = false, + readonly = false, + onSave, + requiresRestart = true, + collapsible = false, + defaultCollapsed = false, + showTitle, +}: ConfigSectionProps) { + const { t, i18n } = useTranslation([ + level === "camera" ? "config/cameras" : "config/global", + "config/cameras", + "views/settings", + "common", + ]); + const [isOpen, setIsOpen] = useState(!defaultCollapsed); + const [pendingData, setPendingData] = useState( + null, + ); + const [isSaving, setIsSaving] = useState(false); + const [formKey, setFormKey] = useState(0); + const isResettingRef = useRef(false); - const ConfigSection = function ConfigSection({ - level, - cameraName, - showOverrideIndicator = true, - sectionConfig = defaultConfig, - disabled = false, - readonly = false, - onSave, - requiresRestart = true, - collapsible = false, - defaultCollapsed = false, - showTitle, - }: BaseSectionProps) { - const { t, i18n } = useTranslation([ - level === "camera" ? "config/cameras" : "config/global", - "config/cameras", - "views/settings", - "common", - ]); - const [isOpen, setIsOpen] = useState(!defaultCollapsed); - const [pendingData, setPendingData] = useState( - null, - ); - const [isSaving, setIsSaving] = useState(false); - const [formKey, setFormKey] = useState(0); - const isResettingRef = useRef(false); + 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"; - const updateTopic = - level === "camera" && cameraName - ? cameraUpdateTopicMap[sectionPath] - ? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}` - : undefined - : `config/${sectionPath}`; + // Fetch config + const { data: config, mutate: refreshConfig } = + useSWR("config"); - // Default: show title for camera level (since it might be collapsible), hide for global - const shouldShowTitle = showTitle ?? level === "camera"; + // Get section schema using cached hook + const sectionSchema = useSectionSchema(sectionPath, level); - // Fetch config - const { data: config, mutate: refreshConfig } = - useSWR("config"); + // Get override status + const { isOverridden, globalValue, cameraValue } = useConfigOverride({ + config, + cameraName: level === "camera" ? cameraName : undefined, + sectionPath, + compareFields: sectionConfig.overrideFields, + }); - // Get section schema using cached hook - const sectionSchema = useSectionSchema(sectionPath, level); + // Get current form data + const rawFormData = useMemo(() => { + if (!config) return {}; - // Get override status - const { isOverridden, globalValue, cameraValue } = useConfigOverride({ - config, - cameraName: level === "camera" ? cameraName : undefined, - sectionPath, - compareFields: sectionConfig.overrideFields, - }); + if (level === "camera" && cameraName) { + return get(config.cameras?.[cameraName], sectionPath) || {}; + } - // Get current form data - const rawFormData = useMemo(() => { - if (!config) return {}; + return get(config, sectionPath) || {}; + }, [config, level, cameraName, sectionPath]); - if (level === "camera" && cameraName) { - return get(config.cameras?.[cameraName], sectionPath) || {}; + const sanitizeSectionData = useCallback( + (data: ConfigSectionData) => { + const normalized = normalizeConfigValue(data) as ConfigSectionData; + if ( + !sectionConfig.hiddenFields || + sectionConfig.hiddenFields.length === 0 + ) { + return normalized; } - return get(config, sectionPath) || {}; - }, [config, level, cameraName]); + const cleaned = cloneDeep(normalized) as ConfigSectionData; + sectionConfig.hiddenFields.forEach((path) => { + if (!path) return; + unset(cleaned, path); + }); + return cleaned; + }, + [sectionConfig.hiddenFields], + ); - const sanitizeSectionData = useCallback( - (data: ConfigSectionData) => { - const normalized = normalizeConfigValue(data) as ConfigSectionData; + const formData = useMemo(() => { + const baseData = sectionSchema + ? applySchemaDefaults(sectionSchema, rawFormData) + : rawFormData; + return sanitizeSectionData(baseData); + }, [rawFormData, sectionSchema, sanitizeSectionData]); + + const schemaDefaults = useMemo(() => { + if (!sectionSchema) { + return {}; + } + return applySchemaDefaults(sectionSchema, {}); + }, [sectionSchema]); + + // Clear pendingData whenever formData changes (e.g., from server refresh) + // This prevents RJSF's initial onChange call from being treated as a user edit + useEffect(() => { + setPendingData(null); + }, [formData]); + + useEffect(() => { + if (isResettingRef.current) { + isResettingRef.current = false; + } + }, [formKey]); + + // Build a minimal overrides payload by comparing `current` against `base` + // (existing config) and `defaults` (schema defaults). + // - Returns `undefined` for null/empty values or when `current` equals `base` + // (or equals `defaults` when `base` is undefined). + // - For objects, recurses and returns an object containing only keys that + // are overridden; returns `undefined` if no keys are overridden. + const buildOverrides = useCallback( + ( + current: unknown, + base: unknown, + defaults: unknown, + ): unknown | undefined => { + if (current === null || current === undefined || current === "") { + return undefined; + } + + if (Array.isArray(current)) { if ( - !sectionConfig.hiddenFields || - sectionConfig.hiddenFields.length === 0 - ) { - return normalized; - } - - const cleaned = cloneDeep(normalized) as ConfigSectionData; - 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]); - - const schemaDefaults = useMemo(() => { - if (!sectionSchema) { - return {}; - } - return applySchemaDefaults(sectionSchema, {}); - }, [sectionSchema]); - - // Clear pendingData whenever formData changes (e.g., from server refresh) - // This prevents RJSF's initial onChange call from being treated as a user edit - useEffect(() => { - setPendingData(null); - }, [formData]); - - useEffect(() => { - if (isResettingRef.current) { - isResettingRef.current = false; - } - }, [formKey]); - - // Build a minimal overrides payload by comparing `current` against `base` - // (existing config) and `defaults` (schema defaults). - // - Returns `undefined` for null/empty values or when `current` equals `base` - // (or equals `defaults` when `base` is undefined). - // - For objects, recurses and returns an object containing only keys that - // are overridden; returns `undefined` if no keys are overridden. - const buildOverrides = useCallback( - ( - current: unknown, - base: unknown, - defaults: unknown, - ): unknown | undefined => { - if (current === null || current === undefined || current === "") { - return undefined; - } - - if (Array.isArray(current)) { - if ( - (base === undefined && - defaults !== undefined && - isEqual(current, defaults)) || - isEqual(current, base) - ) { - return undefined; - } - return current; - } - - if (isJsonObject(current)) { - const currentObj = current; - const baseObj = isJsonObject(base) ? base : undefined; - const defaultsObj = isJsonObject(defaults) ? defaults : undefined; - - const result: JsonObject = {}; - for (const [key, value] of Object.entries(currentObj)) { - const overrideValue = buildOverrides( - value, - baseObj ? baseObj[key] : undefined, - defaultsObj ? defaultsObj[key] : undefined, - ); - if (overrideValue !== undefined) { - result[key] = overrideValue as JsonValue; - } - } - - return Object.keys(result).length > 0 ? result : undefined; - } - - if ( - base === undefined && - defaults !== undefined && - isEqual(current, defaults) + (base === undefined && + defaults !== undefined && + isEqual(current, defaults)) || + isEqual(current, base) ) { return undefined; } - - if (isEqual(current, base)) { - return undefined; - } - return current; - }, - [], - ); + } - // Track if there are unsaved changes - const hasChanges = useMemo(() => { - if (!pendingData) return false; - return !isEqual(formData, pendingData); - }, [formData, pendingData]); + if (isJsonObject(current)) { + const currentObj = current; + const baseObj = isJsonObject(base) ? base : undefined; + const defaultsObj = isJsonObject(defaults) ? defaults : undefined; - // Handle form data change - const handleChange = useCallback( - (data: unknown) => { - if (isResettingRef.current) { - setPendingData(null); - return; - } - if (!data || typeof data !== "object") { - setPendingData(null); - return; - } - const sanitizedData = sanitizeSectionData(data as ConfigSectionData); - if (isEqual(formData, sanitizedData)) { - setPendingData(null); - return; - } - setPendingData(sanitizedData); - }, - [formData, sanitizeSectionData], - ); - - const handleReset = useCallback(() => { - isResettingRef.current = true; - setPendingData(null); - setFormKey((prev) => prev + 1); - }, []); - - // 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, schemaDefaults); - - if (!overrides || Object.keys(overrides).length === 0) { - setPendingData(null); - return; - } - - // await axios.put("config/set", { - // requires_restart: requiresRestart ? 0 : 1, - // update_topic: updateTopic, - // config_data: { - // [basePath]: overrides, - // }, - // }); - - // log save to console for debugging - console.log("Saved config data:", { - [basePath]: overrides, - update_topic: updateTopic, - requires_restart: requiresRestart ? 0 : 1, - }); - - 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", - }), - ); + const result: JsonObject = {}; + for (const [key, value] of Object.entries(currentObj)) { + const overrideValue = buildOverrides( + value, + baseObj ? baseObj[key] : undefined, + defaultsObj ? defaultsObj[key] : undefined, + ); + if (overrideValue !== undefined) { + result[key] = overrideValue as JsonValue; } + } + + return Object.keys(result).length > 0 ? result : undefined; + } + + if ( + base === undefined && + defaults !== undefined && + isEqual(current, defaults) + ) { + return undefined; + } + + if (isEqual(current, base)) { + return undefined; + } + + return current; + }, + [], + ); + + // Track if there are unsaved changes + const hasChanges = useMemo(() => { + if (!pendingData) return false; + return !isEqual(formData, pendingData); + }, [formData, pendingData]); + + // Handle form data change + const handleChange = useCallback( + (data: unknown) => { + if (isResettingRef.current) { + setPendingData(null); + return; + } + if (!data || typeof data !== "object") { + setPendingData(null); + return; + } + const sanitizedData = sanitizeSectionData(data as ConfigSectionData); + if (isEqual(formData, sanitizedData)) { + setPendingData(null); + return; + } + setPendingData(sanitizedData); + }, + [formData, sanitizeSectionData], + ); + + const handleReset = useCallback(() => { + isResettingRef.current = true; + setPendingData(null); + setFormKey((prev) => prev + 1); + }, []); + + // 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, schemaDefaults); + + if (!overrides || Object.keys(overrides).length === 0) { + setPendingData(null); + return; + } + + // await axios.put("config/set", { + // requires_restart: requiresRestart ? 0 : 1, + // update_topic: updateTopic, + // config_data: { + // [basePath]: overrides, + // }, + // }); + // log save to console for debugging + console.log("Saved config data:", { + [basePath]: overrides, + update_topic: updateTopic, + requires_restart: requiresRestart ? 0 : 1, + }); + + 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", { @@ -405,254 +397,238 @@ export function createConfigSection({ }), ); } - } finally { - setIsSaving(false); - } - }, [ - pendingData, - level, - cameraName, - requiresRestart, - t, - refreshConfig, - onSave, - rawFormData, - sanitizeSectionData, - buildOverrides, - schemaDefaults, - updateTopic, - ]); - - // 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 = level === "global" ? schemaDefaults : ""; - - // await axios.put("config/set", { - // requires_restart: requiresRestart ? 0 : 1, - // update_topic: updateTopic, - // config_data: { - // [basePath]: configData, - // }, - // }); - - // log reset to console for debugging - 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 { + } else { toast.error( - t("toast.resetError", { + t("toast.error", { ns: "views/settings", - defaultValue: "Failed to reset settings", + defaultValue: "Failed to save settings", }), ); } - }, [level, cameraName, requiresRestart, t, refreshConfig, updateTopic]); - - if (!sectionSchema) { - return null; + } finally { + setIsSaving(false); } + }, [ + sectionPath, + pendingData, + level, + cameraName, + requiresRestart, + t, + refreshConfig, + onSave, + rawFormData, + sanitizeSectionData, + buildOverrides, + schemaDefaults, + updateTopic, + ]); - // Get section title from config namespace - const defaultTitle = - sectionPath.charAt(0).toUpperCase() + - sectionPath.slice(1).replace(/_/g, " "); + // Handle reset to global/defaults - removes camera-level override or resets global to defaults + const handleResetToGlobal = useCallback(async () => { + if (level === "camera" && !cameraName) return; - // 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, - }); + try { + const basePath = + level === "camera" && cameraName + ? `cameras.${cameraName}.${sectionPath}` + : sectionPath; // const configData = level === "global" ? schemaDefaults : ""; - const sectionDescription = i18n.exists(`${sectionPath}.description`, { - ns: configNamespace, - }) - ? t(`${sectionPath}.description`, { ns: configNamespace }) - : undefined; + // await axios.put("config/set", { + // requires_restart: requiresRestart ? 0 : 1, + // update_topic: updateTopic, + // config_data: { + // [basePath]: configData, + // }, + // }); - const sectionContent = ( -
- + // log reset to console for debugging + 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", + }), + ); - {/* Save button */} -
-
- {hasChanges && ( - - {t("unsavedChanges", { - ns: "views/settings", - defaultValue: "You have unsaved changes", - })} - - )} -
-
- {hasChanges && ( - - )} - -
-
-
- ); - - if (collapsible) { - return ( - -
- -
-
- {isOpen ? ( - - ) : ( - - )} - {title} - {showOverrideIndicator && - level === "camera" && - isOverridden && ( - - {t("overridden", { - ns: "common", - defaultValue: "Overridden", - })} - - )} - {hasChanges && ( - - {t("modified", { - ns: "common", - defaultValue: "Modified", - })} - - )} -
- {((level === "camera" && isOverridden) || - level === "global") && ( - - )} -
-
- - -
{sectionContent}
-
-
-
+ setPendingData(null); + refreshConfig(); + } catch { + toast.error( + t("toast.resetError", { + ns: "views/settings", + defaultValue: "Failed to reset settings", + }), ); } + }, [ + sectionPath, + level, + cameraName, + requiresRestart, + t, + refreshConfig, + updateTopic, + ]); + 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]); + + if (!sectionSchema) { + 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; + + const sectionContent = ( +
+ + + {/* Save button */} +
+
+ {hasChanges && ( + + {t("unsavedChanges", { + ns: "views/settings", + defaultValue: "You have unsaved changes", + })} + + )} +
+
+ {hasChanges && ( + + )} + +
+
+
+ ); + + if (collapsible) { return ( -
- {shouldShowTitle && ( -
-
+ +
+ +
+ {isOpen ? ( + + ) : ( + + )} {title} {showOverrideIndicator && level === "camera" && @@ -666,66 +642,120 @@ export function createConfigSection({ )} {hasChanges && ( - {t("modified", { ns: "common", defaultValue: "Modified" })} + {t("modified", { + ns: "common", + defaultValue: "Modified", + })} )}
- {sectionDescription && ( -

- {sectionDescription} -

+ {((level === "camera" && isOverridden) || level === "global") && ( + )}
- {((level === "camera" && isOverridden) || level === "global") && ( - +
+ + +
{sectionContent}
+
+
+
+ ); + } + + return ( +
+ {shouldShowTitle && ( +
+
+
+ {title} + {showOverrideIndicator && level === "camera" && isOverridden && ( + + {t("overridden", { + ns: "common", + defaultValue: "Overridden", + })} + + )} + {hasChanges && ( + + {t("modified", { ns: "common", defaultValue: "Modified" })} + + )} +
+ {sectionDescription && ( +

+ {sectionDescription} +

)}
+ {((level === "camera" && isOverridden) || level === "global") && ( + + )} +
+ )} + + {/* Reset button when title is hidden but we're at camera level with override */} + {!shouldShowTitle && + ((level === "camera" && isOverridden) || level === "global") && ( +
+ +
)} - {/* Reset button when title is hidden but we're at camera level with override */} - {!shouldShowTitle && - ((level === "camera" && isOverridden) || level === "global") && ( -
- -
- )} - - {sectionContent} -
- ); - }; - - return ConfigSection; + {sectionContent} +
+ ); }