highlight label of changed fields

This commit is contained in:
Josh Hawkins 2026-02-07 17:00:59 -06:00
parent f774b2282b
commit 2ca5d20320
9 changed files with 494 additions and 51 deletions

View File

@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import cloneDeep from "lodash/cloneDeep";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import set from "lodash/set";
import { LuExternalLink } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
@ -36,6 +37,9 @@ export default function CameraReviewClassification({
const { getLocaleDocUrl } = useDocDomain();
const cameraName = formContext?.cameraName ?? selectedCamera;
const fullFormData = formContext?.formData as JsonObject | undefined;
const baselineFormData = formContext?.baselineFormData as
| JsonObject
| undefined;
const cameraConfig = formContext?.fullCameraConfig;
const alertsZones = useMemo(
@ -47,6 +51,25 @@ export default function CameraReviewClassification({
[fullFormData],
);
// Track whether zones have been modified from baseline for label coloring
const alertsZonesModified = useMemo(() => {
if (!baselineFormData) return false;
const baseline = getRequiredZones(
baselineFormData,
"alerts.required_zones",
);
return !isEqual(alertsZones, baseline);
}, [alertsZones, baselineFormData]);
const detectionsZonesModified = useMemo(() => {
if (!baselineFormData) return false;
const baseline = getRequiredZones(
baselineFormData,
"detections.required_zones",
);
return !isEqual(detectionsZones, baseline);
}, [detectionsZones, baselineFormData]);
const [selectDetections, setSelectDetections] = useState(
detectionsZones.length > 0,
);
@ -192,7 +215,12 @@ export default function CameraReviewClassification({
{zones && zones.length > 0 ? (
<>
<div className="mb-2">
<Label className="flex flex-row items-center text-base">
<Label
className={cn(
"flex flex-row items-center text-base",
alertsZonesModified && "text-danger",
)}
>
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
<MdCircle className="ml-3 size-2 text-severity_alert" />
</Label>
@ -255,7 +283,12 @@ export default function CameraReviewClassification({
{zones && zones.length > 0 && (
<>
<div className="mb-2">
<Label className="flex flex-row items-center text-base">
<Label
className={cn(
"flex flex-row items-center text-base",
detectionsZonesModified && "text-danger",
)}
>
<Trans ns="views/settings">
cameraReview.review.detections
</Trans>

View File

@ -192,6 +192,14 @@ export function ConfigSection({
// Use pending data from parent if available, otherwise use local state
const [localPendingData, setLocalPendingData] =
useState<ConfigSectionData | null>(null);
const [pendingOverrides, setPendingOverrides] = useState<
JsonValue | undefined
>(undefined);
const [dirtyOverrides, setDirtyOverrides] = useState<JsonValue | undefined>(
undefined,
);
const [baselineFormData, setBaselineFormData] =
useState<ConfigSectionData | null>(null);
const pendingData =
pendingDataBySection !== undefined
@ -314,17 +322,31 @@ export function ConfigSection({
[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, onPendingDataChange]);
}, [
formData,
pendingData,
setPendingData,
setBaselineFormData,
onPendingDataChange,
]);
useEffect(() => {
if (isResettingRef.current) {
@ -435,58 +457,98 @@ export function ConfigSection({
(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);
// When the server-stored `rawSectionValue` for `motion` global is
// actually `null` we must preserve that `null` sentinel for base
// comparisons. `isMotionGlobal` signals that the stored value is
// intentionally null (meaning "no global override provided"), so the
// baseline used by `buildOverrides` should be `null` rather than an
// object. This keeps the UI from treating the form-populated default
// object as a user edit on initial render.
const isMotionGlobal =
sectionPath === "motion" &&
level === "global" &&
rawSectionValue === null;
const rawData = isMotionGlobal
? null
: sanitizeSectionData(rawFormData as ConfigSectionData);
let nextBaselineFormData = baselineFormData ?? formData;
const overrides = buildOverrides(
sanitizedData,
rawData,
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,
level,
sectionPath,
rawSectionValue,
rawFormData,
compareBaseData,
sanitizeSectionData,
buildOverrides,
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,
buildOverrides,
compareBaseData,
effectiveSchemaDefaults,
]);
const effectiveOverrides = pendingData
? (pendingOverrides ?? currentOverrides)
: undefined;
const uiOverrides = dirtyOverrides ?? effectiveOverrides;
const requiresRestartForOverrides = useCallback(
(overrides: unknown) => {
if (sectionConfig.restartRequired === undefined) {
@ -511,8 +573,10 @@ export function ConfigSection({
const handleReset = useCallback(() => {
isResettingRef.current = true;
setPendingData(null);
setPendingOverrides(undefined);
setDirtyOverrides(undefined);
setFormKey((prev) => prev + 1);
}, [setPendingData]);
}, [setPendingData, setPendingOverrides, setDirtyOverrides]);
// Handle save button click
const handleSave = useCallback(async () => {
@ -808,7 +872,7 @@ export function ConfigSection({
<ConfigForm
key={formKey}
schema={modifiedSchema}
formData={pendingData || formData}
formData={currentFormData}
onChange={handleChange}
fieldOrder={sectionConfig.fieldOrder}
fieldGroups={sectionConfig.fieldGroups}
@ -827,7 +891,9 @@ export function ConfigSection({
globalValue,
cameraValue,
hasChanges,
formData: (pendingData || formData) as ConfigSectionData,
overrides: uiOverrides as JsonValue | undefined,
formData: currentFormData as ConfigSectionData,
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
@ -845,6 +911,7 @@ export function ConfigSection({
renderers: wrappedRenderers,
sectionDocs: sectionConfig.sectionDocs,
fieldDocs: sectionConfig.fieldDocs,
hiddenFields: sectionConfig.hiddenFields,
}}
/>

View File

@ -78,11 +78,15 @@
*/
import type { FieldProps, ObjectFieldTemplateProps } from "@rjsf/utils";
import { useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { ConfigFormContext } from "@/types/configForm";
import { getDomainFromNamespace, humanizeKey } from "../utils/i18n";
import {
getDomainFromNamespace,
hasOverrideAtPath,
humanizeKey,
} from "../utils";
import { AddPropertyButton, AdvancedCollapsible } from "../components";
type LayoutGridColumnConfig = {
@ -126,7 +130,6 @@ function GridLayoutObjectFieldTemplate(
readonly,
} = props;
const formContext = registry?.formContext as ConfigFormContext | undefined;
const [showAdvanced, setShowAdvanced] = useState(false);
const { t } = useTranslation(["common", "config/groups"]);
// Use the original ObjectFieldTemplate passed as parameter, not from registry
@ -145,11 +148,11 @@ function GridLayoutObjectFieldTemplate(
const useGridForAdvanced = layoutGridOptions.useGridForAdvanced ?? true;
const groupDefinitions =
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
const overrides = formContext?.overrides;
const fieldPath = props.fieldPathId.path;
// If no layout grid is defined, use the default template
if (layoutGrid.length === 0) {
return <ObjectFieldTemplate {...props} />;
}
const isPathModified = (path: Array<string | number>) =>
hasOverrideAtPath(overrides, path, formContext?.formData);
// Override the properties rendering with grid layout
const isHiddenProp = (prop: (typeof properties)[number]) =>
@ -164,6 +167,15 @@ function GridLayoutObjectFieldTemplate(
const regularProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
);
const hasModifiedAdvanced = advancedProps.some((prop) =>
isPathModified([...fieldPath, prop.name]),
);
const [showAdvanced, setShowAdvanced] = useState(hasModifiedAdvanced);
// If no layout grid is defined, use the default template
if (layoutGrid.length === 0) {
return <ObjectFieldTemplate {...props} />;
}
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
@ -518,14 +530,22 @@ export function LayoutGridField(props: FieldProps) {
// Create a modified registry with our custom template
// But we'll pass the original template to it to prevent circular reference
const modifiedRegistry = {
...registry,
templates: {
...registry.templates,
ObjectFieldTemplate: (tProps: ObjectFieldTemplateProps) =>
GridLayoutObjectFieldTemplate(tProps, originalObjectFieldTemplate),
},
};
const gridObjectFieldTemplate = useCallback(
(tProps: ObjectFieldTemplateProps) =>
GridLayoutObjectFieldTemplate(tProps, originalObjectFieldTemplate),
[originalObjectFieldTemplate],
);
const modifiedRegistry = useMemo(
() => ({
...registry,
templates: {
...registry.templates,
ObjectFieldTemplate: gridObjectFieldTemplate,
},
}),
[registry, gridObjectFieldTemplate],
);
// Delegate to ObjectField with the modified registry
return (

View File

@ -19,8 +19,13 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
import {
buildTranslationPath,
getFilterObjectLabel,
hasOverrideAtPath,
humanizeKey,
} from "../utils/i18n";
normalizeFieldValue,
} from "../utils";
import { normalizeOverridePath } from "../utils/overrides";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
function _isArrayItemInAdditionalProperty(
pathSegments: Array<string | number>,
@ -66,6 +71,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
onRemoveProperty,
rawDescription,
rawErrors,
formData: fieldFormData,
disabled,
readonly,
} = props;
@ -131,6 +137,30 @@ export function FieldTemplate(props: FieldTemplateProps) {
sectionI18nPrefix,
formContext,
);
const fieldPath = fieldPathId.path;
const overrides = formContext?.overrides;
const baselineFormData = formContext?.baselineFormData;
const normalizedFieldPath = normalizeOverridePath(
fieldPath,
formContext?.formData,
);
let baselineValue = baselineFormData
? get(baselineFormData, normalizedFieldPath)
: undefined;
if (baselineValue === undefined || baselineValue === null) {
if (schema.default !== undefined && schema.default !== null) {
baselineValue = schema.default;
}
}
const isBaselineModified =
baselineFormData !== undefined &&
!isEqual(
normalizeFieldValue(fieldFormData),
normalizeFieldValue(baselineValue),
);
const isModified = baselineFormData
? isBaselineModified
: hasOverrideAtPath(overrides, fieldPath, formContext?.formData);
const filterObjectLabel = getFilterObjectLabel(pathSegments);
const translatedFilterObjectLabel = filterObjectLabel
? getTranslatedLabel(filterObjectLabel, "object")
@ -364,6 +394,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
htmlFor={id}
className={cn(
"text-sm font-medium",
isModified && "text-danger",
errors &&
errors.props?.errors?.length > 0 &&
"text-destructive",
@ -378,7 +409,13 @@ export function FieldTemplate(props: FieldTemplateProps) {
<div className="flex w-full items-center justify-between gap-4">
<div className="space-y-0.5">
{displayLabel && finalLabel && (
<Label htmlFor={id} className="text-sm font-medium">
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
isModified && "text-danger",
)}
>
{finalLabel}
{required && (
<span className="ml-1 text-destructive">*</span>

View File

@ -6,7 +6,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Children, useState } from "react";
import { Children, useState, useEffect } from "react";
import type { ReactNode } from "react";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { useTranslation } from "react-i18next";
@ -15,10 +15,12 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { ConfigFormContext } from "@/types/configForm";
import {
buildTranslationPath,
getDomainFromNamespace,
getFilterObjectLabel,
humanizeKey,
getDomainFromNamespace,
} from "../utils/i18n";
isSubtreeModified,
} from "../utils";
import get from "lodash/get";
import { AddPropertyButton, AdvancedCollapsible } from "../components";
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
@ -38,8 +40,115 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
// Check if this is a root-level object
const isRoot = registry?.rootSchema === schema;
const overrides = formContext?.overrides;
const baselineFormData = formContext?.baselineFormData;
const hiddenFields = formContext?.hiddenFields;
const fieldPath = props.fieldPathId.path;
const [isOpen, setIsOpen] = useState(false);
// Strip fields from an object that should be excluded from modification
// detection: fields listed in hiddenFields (stripped from baseline by
// sanitizeSectionData) and fields with ui:widget=hidden in uiSchema
// (managed by custom components, not the standard form).
const stripExcludedFields = (
data: unknown,
path: Array<string | number>,
): unknown => {
if (
!data ||
typeof data !== "object" ||
Array.isArray(data) ||
data === null
) {
return data;
}
const result = { ...(data as Record<string, unknown>) };
const pathStrings = path.map(String);
// Strip hiddenFields that match the current path prefix
if (hiddenFields) {
for (const hidden of hiddenFields) {
const parts = hidden.split(".");
if (
parts.length === pathStrings.length + 1 &&
pathStrings.every((s, i) => s === parts[i])
) {
delete result[parts[parts.length - 1]];
}
}
}
// Strip ui:widget=hidden fields from uiSchema at this level
if (uiSchema) {
// Navigate to the uiSchema subtree matching the relative path
let subUiSchema = uiSchema;
const relativePath = path.slice(fieldPath.length);
for (const segment of relativePath) {
if (
typeof segment === "string" &&
subUiSchema &&
typeof subUiSchema[segment] === "object"
) {
subUiSchema = subUiSchema[segment] as typeof uiSchema;
} else {
subUiSchema = undefined as unknown as typeof uiSchema;
break;
}
}
if (subUiSchema && typeof subUiSchema === "object") {
for (const [key, propSchema] of Object.entries(subUiSchema)) {
if (
!key.startsWith("ui:") &&
typeof propSchema === "object" &&
propSchema !== null &&
(propSchema as Record<string, unknown>)["ui:widget"] === "hidden"
) {
delete result[key];
}
}
}
}
return result;
};
// Use props.formData (always up-to-date from RJSF) rather than
// formContext.formData which can be stale in parent templates.
const checkSubtreeModified = (path: Array<string | number>): boolean => {
// Compute relative path from this object's fieldPath to get the
// value from props.formData (which represents this object's data)
const relativePath = path.slice(fieldPath.length);
let currentValue =
relativePath.length > 0 ? get(formData, relativePath) : formData;
// Strip hidden/excluded fields from the RJSF data before comparing
// against the baseline (which already has these stripped)
currentValue = stripExcludedFields(currentValue, path);
let baselineValue =
path.length > 0 ? get(baselineFormData, path) : baselineFormData;
// Also strip hidden/excluded fields from the baseline so that fields
// managed by custom components (e.g. required_zones with ui:widget=hidden)
// don't cause false modification detection.
baselineValue = stripExcludedFields(baselineValue, path);
return isSubtreeModified(
currentValue,
baselineValue,
overrides,
path,
formContext?.formData,
);
};
const hasModifiedDescendants = checkSubtreeModified(fieldPath);
const [isOpen, setIsOpen] = useState(hasModifiedDescendants);
// Auto-expand collapsible when modifications are detected
useEffect(() => {
if (hasModifiedDescendants) {
setIsOpen(true);
}
}, [hasModifiedDescendants]);
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : "config/global";
@ -71,8 +180,17 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const regularProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
);
const hasModifiedAdvanced = advancedProps.some((prop) =>
checkSubtreeModified([...fieldPath, prop.name]),
);
const [showAdvanced, setShowAdvanced] = useState(hasModifiedAdvanced);
const [showAdvanced, setShowAdvanced] = useState(false);
// Auto-expand advanced section when modifications are detected
useEffect(() => {
if (hasModifiedAdvanced) {
setShowAdvanced(true);
}
}, [hasModifiedAdvanced]);
const { children } = props as ObjectFieldTemplateProps & {
children?: ReactNode;
};
@ -290,7 +408,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-sm">{inferredLabel}</CardTitle>
<CardTitle
className={cn(
"text-sm",
hasModifiedDescendants && "text-danger",
)}
>
{inferredLabel}
</CardTitle>
{inferredDescription && (
<p className="mt-1 text-xs text-muted-foreground">
{inferredDescription}

View File

@ -8,3 +8,10 @@ export {
humanizeKey,
getDomainFromNamespace,
} from "./i18n";
export { getOverrideAtPath, hasOverrideAtPath } from "./overrides";
export {
deepNormalizeValue,
normalizeFieldValue,
isSubtreeModified,
} from "./overrides";

View File

@ -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<string | number>,
) => {
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<string | number>,
data: JsonValue | undefined,
) => {
if (data === undefined || data === null) {
return path;
}
const normalized: Array<string | number> = [];
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<string, JsonValue>)[segment];
} else {
cursor = undefined;
}
}
return normalized;
};
export const hasOverrideAtPath = (
overrides: JsonValue | undefined,
path: Array<string | number>,
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<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
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<string | number>,
contextData?: JsonValue,
): boolean => {
if (baselineData !== undefined || currentData !== undefined) {
return !isEqual(
deepNormalizeValue(currentData),
deepNormalizeValue(baselineData),
);
}
return hasOverrideAtPath(overrides, path, contextData);
};

View File

@ -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<string, unknown>;
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<string, unknown>).type !== "null" &&
isSchemaObject((s as Record<string, unknown>).properties),
) as Record<string, unknown> | undefined;
if (objectBranch) {
properties = objectBranch.properties;
}
}
}
if (!isSchemaObject(properties)) {
return result;
}
for (const [key, prop] of Object.entries(
schemaObj.properties as Record<string, unknown>,
properties as Record<string, unknown>,
)) {
if (!isSchemaObject(prop)) continue;

View File

@ -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;