mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-18 14:18:21 +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 { Trans, useTranslation } from "react-i18next";
|
||||||
import cloneDeep from "lodash/cloneDeep";
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
import get from "lodash/get";
|
import get from "lodash/get";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
import set from "lodash/set";
|
import set from "lodash/set";
|
||||||
import { LuExternalLink } from "react-icons/lu";
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
import { MdCircle } from "react-icons/md";
|
import { MdCircle } from "react-icons/md";
|
||||||
@ -36,6 +37,9 @@ export default function CameraReviewClassification({
|
|||||||
const { getLocaleDocUrl } = useDocDomain();
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
const cameraName = formContext?.cameraName ?? selectedCamera;
|
const cameraName = formContext?.cameraName ?? selectedCamera;
|
||||||
const fullFormData = formContext?.formData as JsonObject | undefined;
|
const fullFormData = formContext?.formData as JsonObject | undefined;
|
||||||
|
const baselineFormData = formContext?.baselineFormData as
|
||||||
|
| JsonObject
|
||||||
|
| undefined;
|
||||||
const cameraConfig = formContext?.fullCameraConfig;
|
const cameraConfig = formContext?.fullCameraConfig;
|
||||||
|
|
||||||
const alertsZones = useMemo(
|
const alertsZones = useMemo(
|
||||||
@ -47,6 +51,25 @@ export default function CameraReviewClassification({
|
|||||||
[fullFormData],
|
[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(
|
const [selectDetections, setSelectDetections] = useState(
|
||||||
detectionsZones.length > 0,
|
detectionsZones.length > 0,
|
||||||
);
|
);
|
||||||
@ -192,7 +215,12 @@ export default function CameraReviewClassification({
|
|||||||
{zones && zones.length > 0 ? (
|
{zones && zones.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2">
|
<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>
|
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
|
||||||
<MdCircle className="ml-3 size-2 text-severity_alert" />
|
<MdCircle className="ml-3 size-2 text-severity_alert" />
|
||||||
</Label>
|
</Label>
|
||||||
@ -255,7 +283,12 @@ export default function CameraReviewClassification({
|
|||||||
{zones && zones.length > 0 && (
|
{zones && zones.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2">
|
<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">
|
<Trans ns="views/settings">
|
||||||
cameraReview.review.detections
|
cameraReview.review.detections
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|||||||
@ -192,6 +192,14 @@ export function ConfigSection({
|
|||||||
// Use pending data from parent if available, otherwise use local state
|
// Use pending data from parent if available, otherwise use local state
|
||||||
const [localPendingData, setLocalPendingData] =
|
const [localPendingData, setLocalPendingData] =
|
||||||
useState<ConfigSectionData | null>(null);
|
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 =
|
const pendingData =
|
||||||
pendingDataBySection !== undefined
|
pendingDataBySection !== undefined
|
||||||
@ -314,17 +322,31 @@ export function ConfigSection({
|
|||||||
[level, schemaDefaults, sectionPath, modifiedSchema],
|
[level, schemaDefaults, sectionPath, modifiedSchema],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const compareBaseData = useMemo(
|
||||||
|
() => sanitizeSectionData(rawFormData as ConfigSectionData),
|
||||||
|
[rawFormData, sanitizeSectionData],
|
||||||
|
);
|
||||||
|
|
||||||
// Clear pendingData whenever formData changes (e.g., from server refresh)
|
// Clear pendingData whenever formData changes (e.g., from server refresh)
|
||||||
// This prevents RJSF's initial onChange call from being treated as a user edit
|
// This prevents RJSF's initial onChange call from being treated as a user edit
|
||||||
// Only clear if pendingData is managed locally (not by parent)
|
// Only clear if pendingData is managed locally (not by parent)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingData) {
|
if (!pendingData) {
|
||||||
isInitializingRef.current = true;
|
isInitializingRef.current = true;
|
||||||
|
setPendingOverrides(undefined);
|
||||||
|
setDirtyOverrides(undefined);
|
||||||
|
setBaselineFormData(cloneDeep(formData as ConfigSectionData));
|
||||||
}
|
}
|
||||||
if (onPendingDataChange === undefined) {
|
if (onPendingDataChange === undefined) {
|
||||||
setPendingData(null);
|
setPendingData(null);
|
||||||
}
|
}
|
||||||
}, [formData, pendingData, setPendingData, onPendingDataChange]);
|
}, [
|
||||||
|
formData,
|
||||||
|
pendingData,
|
||||||
|
setPendingData,
|
||||||
|
setBaselineFormData,
|
||||||
|
onPendingDataChange,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isResettingRef.current) {
|
if (isResettingRef.current) {
|
||||||
@ -435,58 +457,98 @@ export function ConfigSection({
|
|||||||
(data: unknown) => {
|
(data: unknown) => {
|
||||||
if (isResettingRef.current) {
|
if (isResettingRef.current) {
|
||||||
setPendingData(null);
|
setPendingData(null);
|
||||||
|
setPendingOverrides(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!data || typeof data !== "object") {
|
if (!data || typeof data !== "object") {
|
||||||
setPendingData(null);
|
setPendingData(null);
|
||||||
|
setPendingOverrides(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
|
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
|
||||||
// When the server-stored `rawSectionValue` for `motion` global is
|
let nextBaselineFormData = baselineFormData ?? formData;
|
||||||
// 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);
|
|
||||||
const overrides = buildOverrides(
|
const overrides = buildOverrides(
|
||||||
sanitizedData,
|
sanitizedData,
|
||||||
rawData,
|
compareBaseData,
|
||||||
effectiveSchemaDefaults,
|
effectiveSchemaDefaults,
|
||||||
);
|
);
|
||||||
|
setPendingOverrides(overrides as JsonValue | undefined);
|
||||||
if (isInitializingRef.current && !pendingData) {
|
if (isInitializingRef.current && !pendingData) {
|
||||||
isInitializingRef.current = false;
|
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) {
|
if (overrides === undefined) {
|
||||||
setPendingData(null);
|
setPendingData(null);
|
||||||
|
setPendingOverrides(undefined);
|
||||||
|
setDirtyOverrides(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const dirty = buildOverrides(
|
||||||
|
sanitizedData,
|
||||||
|
nextBaselineFormData,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
setDirtyOverrides(dirty as JsonValue | undefined);
|
||||||
if (overrides === undefined) {
|
if (overrides === undefined) {
|
||||||
setPendingData(null);
|
setPendingData(null);
|
||||||
|
setPendingOverrides(undefined);
|
||||||
|
setDirtyOverrides(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPendingData(sanitizedData);
|
setPendingData(sanitizedData);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
pendingData,
|
pendingData,
|
||||||
level,
|
compareBaseData,
|
||||||
sectionPath,
|
|
||||||
rawSectionValue,
|
|
||||||
rawFormData,
|
|
||||||
sanitizeSectionData,
|
sanitizeSectionData,
|
||||||
buildOverrides,
|
buildOverrides,
|
||||||
effectiveSchemaDefaults,
|
effectiveSchemaDefaults,
|
||||||
setPendingData,
|
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(
|
const requiresRestartForOverrides = useCallback(
|
||||||
(overrides: unknown) => {
|
(overrides: unknown) => {
|
||||||
if (sectionConfig.restartRequired === undefined) {
|
if (sectionConfig.restartRequired === undefined) {
|
||||||
@ -511,8 +573,10 @@ export function ConfigSection({
|
|||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
isResettingRef.current = true;
|
isResettingRef.current = true;
|
||||||
setPendingData(null);
|
setPendingData(null);
|
||||||
|
setPendingOverrides(undefined);
|
||||||
|
setDirtyOverrides(undefined);
|
||||||
setFormKey((prev) => prev + 1);
|
setFormKey((prev) => prev + 1);
|
||||||
}, [setPendingData]);
|
}, [setPendingData, setPendingOverrides, setDirtyOverrides]);
|
||||||
|
|
||||||
// Handle save button click
|
// Handle save button click
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
@ -808,7 +872,7 @@ export function ConfigSection({
|
|||||||
<ConfigForm
|
<ConfigForm
|
||||||
key={formKey}
|
key={formKey}
|
||||||
schema={modifiedSchema}
|
schema={modifiedSchema}
|
||||||
formData={pendingData || formData}
|
formData={currentFormData}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
fieldOrder={sectionConfig.fieldOrder}
|
fieldOrder={sectionConfig.fieldOrder}
|
||||||
fieldGroups={sectionConfig.fieldGroups}
|
fieldGroups={sectionConfig.fieldGroups}
|
||||||
@ -827,7 +891,9 @@ export function ConfigSection({
|
|||||||
globalValue,
|
globalValue,
|
||||||
cameraValue,
|
cameraValue,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
formData: (pendingData || formData) as ConfigSectionData,
|
overrides: uiOverrides as JsonValue | undefined,
|
||||||
|
formData: currentFormData as ConfigSectionData,
|
||||||
|
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||||
// For widgets that need access to full camera config (e.g., zone names)
|
// For widgets that need access to full camera config (e.g., zone names)
|
||||||
fullCameraConfig:
|
fullCameraConfig:
|
||||||
@ -845,6 +911,7 @@ export function ConfigSection({
|
|||||||
renderers: wrappedRenderers,
|
renderers: wrappedRenderers,
|
||||||
sectionDocs: sectionConfig.sectionDocs,
|
sectionDocs: sectionConfig.sectionDocs,
|
||||||
fieldDocs: sectionConfig.fieldDocs,
|
fieldDocs: sectionConfig.fieldDocs,
|
||||||
|
hiddenFields: sectionConfig.hiddenFields,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -78,11 +78,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FieldProps, ObjectFieldTemplateProps } from "@rjsf/utils";
|
import type { FieldProps, ObjectFieldTemplateProps } from "@rjsf/utils";
|
||||||
import { useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ConfigFormContext } from "@/types/configForm";
|
import { ConfigFormContext } from "@/types/configForm";
|
||||||
import { getDomainFromNamespace, humanizeKey } from "../utils/i18n";
|
import {
|
||||||
|
getDomainFromNamespace,
|
||||||
|
hasOverrideAtPath,
|
||||||
|
humanizeKey,
|
||||||
|
} from "../utils";
|
||||||
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
||||||
|
|
||||||
type LayoutGridColumnConfig = {
|
type LayoutGridColumnConfig = {
|
||||||
@ -126,7 +130,6 @@ function GridLayoutObjectFieldTemplate(
|
|||||||
readonly,
|
readonly,
|
||||||
} = props;
|
} = props;
|
||||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
||||||
const { t } = useTranslation(["common", "config/groups"]);
|
const { t } = useTranslation(["common", "config/groups"]);
|
||||||
|
|
||||||
// Use the original ObjectFieldTemplate passed as parameter, not from registry
|
// Use the original ObjectFieldTemplate passed as parameter, not from registry
|
||||||
@ -145,11 +148,11 @@ function GridLayoutObjectFieldTemplate(
|
|||||||
const useGridForAdvanced = layoutGridOptions.useGridForAdvanced ?? true;
|
const useGridForAdvanced = layoutGridOptions.useGridForAdvanced ?? true;
|
||||||
const groupDefinitions =
|
const groupDefinitions =
|
||||||
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
|
(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
|
const isPathModified = (path: Array<string | number>) =>
|
||||||
if (layoutGrid.length === 0) {
|
hasOverrideAtPath(overrides, path, formContext?.formData);
|
||||||
return <ObjectFieldTemplate {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override the properties rendering with grid layout
|
// Override the properties rendering with grid layout
|
||||||
const isHiddenProp = (prop: (typeof properties)[number]) =>
|
const isHiddenProp = (prop: (typeof properties)[number]) =>
|
||||||
@ -164,6 +167,15 @@ function GridLayoutObjectFieldTemplate(
|
|||||||
const regularProps = visibleProps.filter(
|
const regularProps = visibleProps.filter(
|
||||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
(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 domain = getDomainFromNamespace(formContext?.i18nNamespace);
|
||||||
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
|
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
|
||||||
@ -518,14 +530,22 @@ export function LayoutGridField(props: FieldProps) {
|
|||||||
|
|
||||||
// Create a modified registry with our custom template
|
// Create a modified registry with our custom template
|
||||||
// But we'll pass the original template to it to prevent circular reference
|
// But we'll pass the original template to it to prevent circular reference
|
||||||
const modifiedRegistry = {
|
const gridObjectFieldTemplate = useCallback(
|
||||||
...registry,
|
(tProps: ObjectFieldTemplateProps) =>
|
||||||
templates: {
|
GridLayoutObjectFieldTemplate(tProps, originalObjectFieldTemplate),
|
||||||
...registry.templates,
|
[originalObjectFieldTemplate],
|
||||||
ObjectFieldTemplate: (tProps: ObjectFieldTemplateProps) =>
|
);
|
||||||
GridLayoutObjectFieldTemplate(tProps, originalObjectFieldTemplate),
|
|
||||||
},
|
const modifiedRegistry = useMemo(
|
||||||
};
|
() => ({
|
||||||
|
...registry,
|
||||||
|
templates: {
|
||||||
|
...registry.templates,
|
||||||
|
ObjectFieldTemplate: gridObjectFieldTemplate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[registry, gridObjectFieldTemplate],
|
||||||
|
);
|
||||||
|
|
||||||
// Delegate to ObjectField with the modified registry
|
// Delegate to ObjectField with the modified registry
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -19,8 +19,13 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
|
|||||||
import {
|
import {
|
||||||
buildTranslationPath,
|
buildTranslationPath,
|
||||||
getFilterObjectLabel,
|
getFilterObjectLabel,
|
||||||
|
hasOverrideAtPath,
|
||||||
humanizeKey,
|
humanizeKey,
|
||||||
} from "../utils/i18n";
|
normalizeFieldValue,
|
||||||
|
} from "../utils";
|
||||||
|
import { normalizeOverridePath } from "../utils/overrides";
|
||||||
|
import get from "lodash/get";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
|
||||||
function _isArrayItemInAdditionalProperty(
|
function _isArrayItemInAdditionalProperty(
|
||||||
pathSegments: Array<string | number>,
|
pathSegments: Array<string | number>,
|
||||||
@ -66,6 +71,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
onRemoveProperty,
|
onRemoveProperty,
|
||||||
rawDescription,
|
rawDescription,
|
||||||
rawErrors,
|
rawErrors,
|
||||||
|
formData: fieldFormData,
|
||||||
disabled,
|
disabled,
|
||||||
readonly,
|
readonly,
|
||||||
} = props;
|
} = props;
|
||||||
@ -131,6 +137,30 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
sectionI18nPrefix,
|
sectionI18nPrefix,
|
||||||
formContext,
|
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 filterObjectLabel = getFilterObjectLabel(pathSegments);
|
||||||
const translatedFilterObjectLabel = filterObjectLabel
|
const translatedFilterObjectLabel = filterObjectLabel
|
||||||
? getTranslatedLabel(filterObjectLabel, "object")
|
? getTranslatedLabel(filterObjectLabel, "object")
|
||||||
@ -364,6 +394,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-medium",
|
"text-sm font-medium",
|
||||||
|
isModified && "text-danger",
|
||||||
errors &&
|
errors &&
|
||||||
errors.props?.errors?.length > 0 &&
|
errors.props?.errors?.length > 0 &&
|
||||||
"text-destructive",
|
"text-destructive",
|
||||||
@ -378,7 +409,13 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
<div className="flex w-full items-center justify-between gap-4">
|
<div className="flex w-full items-center justify-between gap-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{displayLabel && finalLabel && (
|
{displayLabel && finalLabel && (
|
||||||
<Label htmlFor={id} className="text-sm font-medium">
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
isModified && "text-danger",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{finalLabel}
|
{finalLabel}
|
||||||
{required && (
|
{required && (
|
||||||
<span className="ml-1 text-destructive">*</span>
|
<span className="ml-1 text-destructive">*</span>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import { Children, useState } from "react";
|
import { Children, useState, useEffect } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -15,10 +15,12 @@ import { getTranslatedLabel } from "@/utils/i18n";
|
|||||||
import { ConfigFormContext } from "@/types/configForm";
|
import { ConfigFormContext } from "@/types/configForm";
|
||||||
import {
|
import {
|
||||||
buildTranslationPath,
|
buildTranslationPath,
|
||||||
|
getDomainFromNamespace,
|
||||||
getFilterObjectLabel,
|
getFilterObjectLabel,
|
||||||
humanizeKey,
|
humanizeKey,
|
||||||
getDomainFromNamespace,
|
isSubtreeModified,
|
||||||
} from "../utils/i18n";
|
} from "../utils";
|
||||||
|
import get from "lodash/get";
|
||||||
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
||||||
|
|
||||||
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||||
@ -38,8 +40,115 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
|
|
||||||
// Check if this is a root-level object
|
// Check if this is a root-level object
|
||||||
const isRoot = registry?.rootSchema === schema;
|
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 isCameraLevel = formContext?.level === "camera";
|
||||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : "config/global";
|
const effectiveNamespace = isCameraLevel ? "config/cameras" : "config/global";
|
||||||
@ -71,8 +180,17 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
const regularProps = visibleProps.filter(
|
const regularProps = visibleProps.filter(
|
||||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
(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 & {
|
const { children } = props as ObjectFieldTemplateProps & {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
@ -290,7 +408,14 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|||||||
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
|
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-sm">{inferredLabel}</CardTitle>
|
<CardTitle
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
hasModifiedDescendants && "text-danger",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{inferredLabel}
|
||||||
|
</CardTitle>
|
||||||
{inferredDescription && (
|
{inferredDescription && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{inferredDescription}
|
{inferredDescription}
|
||||||
|
|||||||
@ -8,3 +8,10 @@ export {
|
|||||||
humanizeKey,
|
humanizeKey,
|
||||||
getDomainFromNamespace,
|
getDomainFromNamespace,
|
||||||
} from "./i18n";
|
} 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(
|
export function applySchemaDefaults(
|
||||||
schema: RJSFSchema,
|
schema: RJSFSchema,
|
||||||
@ -615,12 +618,32 @@ export function applySchemaDefaults(
|
|||||||
const result = { ...formData };
|
const result = { ...formData };
|
||||||
const schemaObj = schema as Record<string, unknown>;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, prop] of Object.entries(
|
for (const [key, prop] of Object.entries(
|
||||||
schemaObj.properties as Record<string, unknown>,
|
properties as Record<string, unknown>,
|
||||||
)) {
|
)) {
|
||||||
if (!isSchemaObject(prop)) continue;
|
if (!isSchemaObject(prop)) continue;
|
||||||
|
|
||||||
|
|||||||
@ -18,8 +18,11 @@ export type ConfigFormContext = {
|
|||||||
cameraName?: string;
|
cameraName?: string;
|
||||||
globalValue?: JsonValue;
|
globalValue?: JsonValue;
|
||||||
cameraValue?: JsonValue;
|
cameraValue?: JsonValue;
|
||||||
|
overrides?: JsonValue;
|
||||||
hasChanges?: boolean;
|
hasChanges?: boolean;
|
||||||
formData?: JsonObject;
|
formData?: JsonObject;
|
||||||
|
baselineFormData?: JsonObject;
|
||||||
|
hiddenFields?: string[];
|
||||||
onFormDataChange?: (data: ConfigSectionData) => void;
|
onFormDataChange?: (data: ConfigSectionData) => void;
|
||||||
fullCameraConfig?: CameraConfig;
|
fullCameraConfig?: CameraConfig;
|
||||||
fullConfig?: FrigateConfig;
|
fullConfig?: FrigateConfig;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user