-
{inferredLabel}
+
+ {inferredLabel}
+
{inferredDescription && (
{inferredDescription}
diff --git a/web/src/components/config-form/theme/utils/index.ts b/web/src/components/config-form/theme/utils/index.ts
index 45ede5eaf..c79e339fa 100644
--- a/web/src/components/config-form/theme/utils/index.ts
+++ b/web/src/components/config-form/theme/utils/index.ts
@@ -8,3 +8,10 @@ export {
humanizeKey,
getDomainFromNamespace,
} from "./i18n";
+
+export { getOverrideAtPath, hasOverrideAtPath } from "./overrides";
+export {
+ deepNormalizeValue,
+ normalizeFieldValue,
+ isSubtreeModified,
+} from "./overrides";
diff --git a/web/src/components/config-form/theme/utils/overrides.ts b/web/src/components/config-form/theme/utils/overrides.ts
new file mode 100644
index 000000000..9e94fbdf2
--- /dev/null
+++ b/web/src/components/config-form/theme/utils/overrides.ts
@@ -0,0 +1,128 @@
+import get from "lodash/get";
+import isEqual from "lodash/isEqual";
+import { isJsonObject } from "@/lib/utils";
+import type { JsonValue } from "@/types/configForm";
+
+export const getOverrideAtPath = (
+ overrides: JsonValue | undefined,
+ path: Array,
+) => {
+ if (overrides === undefined || overrides === null) {
+ return undefined;
+ }
+
+ if (isJsonObject(overrides) || Array.isArray(overrides)) {
+ return get(overrides, path);
+ }
+
+ return path.length === 0 ? overrides : undefined;
+};
+
+export const normalizeOverridePath = (
+ path: Array,
+ data: JsonValue | undefined,
+) => {
+ if (data === undefined || data === null) {
+ return path;
+ }
+
+ const normalized: Array = [];
+ let cursor: JsonValue | undefined = data;
+
+ for (const segment of path) {
+ if (typeof segment === "number") {
+ if (Array.isArray(cursor)) {
+ normalized.push(segment);
+ cursor = cursor[segment] as JsonValue | undefined;
+ }
+ continue;
+ }
+
+ normalized.push(segment);
+
+ if (isJsonObject(cursor) || Array.isArray(cursor)) {
+ cursor = (cursor as Record)[segment];
+ } else {
+ cursor = undefined;
+ }
+ }
+
+ return normalized;
+};
+
+export const hasOverrideAtPath = (
+ overrides: JsonValue | undefined,
+ path: Array,
+ contextData?: JsonValue,
+) => {
+ const normalizedPath = contextData
+ ? normalizeOverridePath(path, contextData)
+ : path;
+ const value = getOverrideAtPath(overrides, normalizedPath);
+ if (value !== undefined) {
+ return true;
+ }
+ const shouldFallback =
+ normalizedPath.length !== path.length ||
+ normalizedPath.some((segment, index) => segment !== path[index]);
+ if (!shouldFallback) {
+ return false;
+ }
+ return getOverrideAtPath(overrides, path) !== undefined;
+};
+
+/**
+ * Deep normalization for form data comparison. Strips null, undefined,
+ * and empty-string values from objects and arrays so that RJSF-injected
+ * schema defaults (e.g., `mask: null`) don't cause false positives
+ * against a baseline that lacks those keys.
+ */
+export const deepNormalizeValue = (value: unknown): unknown => {
+ if (value === null || value === undefined || value === "") return undefined;
+ if (Array.isArray(value)) return value.map(deepNormalizeValue);
+ if (typeof value === "object" && value !== null) {
+ const result: Record = {};
+ for (const [k, v] of Object.entries(value as Record)) {
+ const normalized = deepNormalizeValue(v);
+ if (normalized !== undefined) {
+ result[k] = normalized;
+ }
+ }
+ return Object.keys(result).length > 0 ? result : undefined;
+ }
+ return value;
+};
+
+/**
+ * Shallow normalization for individual field values.
+ * Treats null and empty-string as equivalent to undefined.
+ */
+export const normalizeFieldValue = (value: unknown): unknown =>
+ value === null || value === "" ? undefined : value;
+
+/**
+ * Check whether a subtree of form data has been modified relative to
+ * the baseline. Uses deep normalization to ignore RJSF-injected null/empty
+ * schema defaults.
+ *
+ * @param currentData - The current value at the subtree (from props.formData)
+ * @param baselineData - The baseline value at the subtree (from formContext.baselineFormData)
+ * @param overrides - Fallback: the overrides object from formContext
+ * @param path - The full field path for the fallback override check
+ * @param contextData - The full form data for normalizing the override path
+ */
+export const isSubtreeModified = (
+ currentData: unknown,
+ baselineData: unknown,
+ overrides: JsonValue | undefined,
+ path: Array,
+ contextData?: JsonValue,
+): boolean => {
+ if (baselineData !== undefined || currentData !== undefined) {
+ return !isEqual(
+ deepNormalizeValue(currentData),
+ deepNormalizeValue(baselineData),
+ );
+ }
+ return hasOverrideAtPath(overrides, path, contextData);
+};
diff --git a/web/src/lib/config-schema/transformer.ts b/web/src/lib/config-schema/transformer.ts
index 8681266fa..d076b4167 100644
--- a/web/src/lib/config-schema/transformer.ts
+++ b/web/src/lib/config-schema/transformer.ts
@@ -606,7 +606,10 @@ export function extractSchemaSection(
}
/**
- * Merges default values from schema into form data
+ * Merges default values from schema into form data.
+ *
+ * Handles anyOf/oneOf schemas (e.g., `anyOf: [MotionConfig, null]`) by
+ * finding the non-null object branch and applying its property defaults.
*/
export function applySchemaDefaults(
schema: RJSFSchema,
@@ -615,12 +618,32 @@ export function applySchemaDefaults(
const result = { ...formData };
const schemaObj = schema as Record;
- if (!isSchemaObject(schemaObj.properties)) {
+ // Resolve properties, falling back to the non-null object branch of
+ // anyOf/oneOf schemas when top-level properties are not present.
+ let properties = schemaObj.properties;
+ if (!isSchemaObject(properties)) {
+ const branches = (schemaObj.anyOf ?? schemaObj.oneOf) as
+ | unknown[]
+ | undefined;
+ if (Array.isArray(branches)) {
+ const objectBranch = branches.find(
+ (s) =>
+ isSchemaObject(s) &&
+ (s as Record).type !== "null" &&
+ isSchemaObject((s as Record).properties),
+ ) as Record | undefined;
+ if (objectBranch) {
+ properties = objectBranch.properties;
+ }
+ }
+ }
+
+ if (!isSchemaObject(properties)) {
return result;
}
for (const [key, prop] of Object.entries(
- schemaObj.properties as Record,
+ properties as Record,
)) {
if (!isSchemaObject(prop)) continue;
diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts
index 02e718060..93659d328 100644
--- a/web/src/types/configForm.ts
+++ b/web/src/types/configForm.ts
@@ -18,8 +18,11 @@ export type ConfigFormContext = {
cameraName?: string;
globalValue?: JsonValue;
cameraValue?: JsonValue;
+ overrides?: JsonValue;
hasChanges?: boolean;
formData?: JsonObject;
+ baselineFormData?: JsonObject;
+ hiddenFields?: string[];
onFormDataChange?: (data: ConfigSectionData) => void;
fullCameraConfig?: CameraConfig;
fullConfig?: FrigateConfig;