mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
highlight label of changed fields
This commit is contained in:
parent
f774b2282b
commit
2ca5d20320
@ -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>
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -8,3 +8,10 @@ export {
|
||||
humanizeKey,
|
||||
getDomainFromNamespace,
|
||||
} from "./i18n";
|
||||
|
||||
export { getOverrideAtPath, hasOverrideAtPath } from "./overrides";
|
||||
export {
|
||||
deepNormalizeValue,
|
||||
normalizeFieldValue,
|
||||
isSubtreeModified,
|
||||
} from "./overrides";
|
||||
|
||||
128
web/src/components/config-form/theme/utils/overrides.ts
Normal file
128
web/src/components/config-form/theme/utils/overrides.ts
Normal 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);
|
||||
};
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user