mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-10 05:51:14 +03:00
fix stale field message after reverting a conditional form field
Routes field-level conditional messages through a dedicated React Context instead of merging them into uiSchema. RJSF's Form keeps state.uiSchema sticky across renders during processPendingChange (formData is updated, uiSchema is not), so a previously injected ui:messages array stays attached to a field even after the triggering condition flips back to false. Context propagation re-runs FieldTemplate directly on every provider value change, sidestepping that staleness.
This commit is contained in:
parent
1cb8976b26
commit
173652e672
13
web/src/components/config-form/FieldMessagesContext.ts
Normal file
13
web/src/components/config-form/FieldMessagesContext.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
import type { FieldConditionalMessage } from "./section-configs/types";
|
||||||
|
|
||||||
|
// Provides currently-active field messages to FieldTemplate without going
|
||||||
|
// through RJSF's per-field uiSchema. RJSF caches state.uiSchema across renders
|
||||||
|
// in a way that can leave stale ui:messages attached to a field when the
|
||||||
|
// triggering condition flips back to false (see processPendingChange in
|
||||||
|
// @rjsf/core Form.js — formData is updated immediately, uiSchema is not).
|
||||||
|
// useContext re-runs consumers directly on provider value change, sidestepping
|
||||||
|
// that staleness.
|
||||||
|
export const FieldMessagesContext = createContext<FieldConditionalMessage[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
@ -86,6 +86,7 @@ import type {
|
|||||||
} from "../section-configs/types";
|
} from "../section-configs/types";
|
||||||
import { useConfigMessages } from "@/hooks/use-config-messages";
|
import { useConfigMessages } from "@/hooks/use-config-messages";
|
||||||
import { ConfigMessageBanner } from "../ConfigMessageBanner";
|
import { ConfigMessageBanner } from "../ConfigMessageBanner";
|
||||||
|
import { FieldMessagesContext } from "../FieldMessagesContext";
|
||||||
|
|
||||||
export interface SectionConfig {
|
export interface SectionConfig {
|
||||||
/** Field ordering within the section */
|
/** Field ordering within the section */
|
||||||
@ -627,44 +628,6 @@ export function ConfigSection({
|
|||||||
messageContext,
|
messageContext,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Merge field-level conditional messages into uiSchema
|
|
||||||
const effectiveUiSchema = useMemo(() => {
|
|
||||||
if (activeFieldMessages.length === 0) return sectionConfig.uiSchema;
|
|
||||||
const merged = { ...(sectionConfig.uiSchema ?? {}) };
|
|
||||||
for (const msg of activeFieldMessages) {
|
|
||||||
const segments = msg.field.split(".");
|
|
||||||
// Navigate to the nested uiSchema node, shallow-cloning along the way
|
|
||||||
let node = merged;
|
|
||||||
for (let i = 0; i < segments.length - 1; i++) {
|
|
||||||
const seg = segments[i];
|
|
||||||
node[seg] = { ...(node[seg] as Record<string, unknown>) };
|
|
||||||
node = node[seg] as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
const leafKey = segments[segments.length - 1];
|
|
||||||
const existing = node[leafKey] as Record<string, unknown> | undefined;
|
|
||||||
const existingMessages = ((existing?.["ui:messages"] as unknown[]) ??
|
|
||||||
[]) as Array<{
|
|
||||||
key: string;
|
|
||||||
messageKey: string;
|
|
||||||
severity: string;
|
|
||||||
position?: string;
|
|
||||||
}>;
|
|
||||||
node[leafKey] = {
|
|
||||||
...existing,
|
|
||||||
"ui:messages": [
|
|
||||||
...existingMessages,
|
|
||||||
{
|
|
||||||
key: msg.key,
|
|
||||||
messageKey: msg.messageKey,
|
|
||||||
severity: msg.severity,
|
|
||||||
position: msg.position ?? "before",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
}, [sectionConfig.uiSchema, activeFieldMessages]);
|
|
||||||
|
|
||||||
const currentOverrides = useMemo(() => {
|
const currentOverrides = useMemo(() => {
|
||||||
if (!currentFormData || typeof currentFormData !== "object") {
|
if (!currentFormData || typeof currentFormData !== "object") {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -1034,59 +997,61 @@ export function ConfigSection({
|
|||||||
const sectionContent = (
|
const sectionContent = (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ConfigMessageBanner messages={activeMessages} />
|
<ConfigMessageBanner messages={activeMessages} />
|
||||||
<ConfigForm
|
<FieldMessagesContext.Provider value={activeFieldMessages}>
|
||||||
key={formKey}
|
<ConfigForm
|
||||||
schema={modifiedSchema}
|
key={formKey}
|
||||||
formData={currentFormData}
|
schema={modifiedSchema}
|
||||||
onChange={handleChange}
|
formData={currentFormData}
|
||||||
onValidationChange={setHasValidationErrors}
|
onChange={handleChange}
|
||||||
fieldOrder={sectionConfig.fieldOrder}
|
onValidationChange={setHasValidationErrors}
|
||||||
fieldGroups={sectionConfig.fieldGroups}
|
fieldOrder={sectionConfig.fieldOrder}
|
||||||
hiddenFields={effectiveHiddenFields}
|
fieldGroups={sectionConfig.fieldGroups}
|
||||||
advancedFields={sectionConfig.advancedFields}
|
hiddenFields={effectiveHiddenFields}
|
||||||
liveValidate={sectionConfig.liveValidate}
|
advancedFields={sectionConfig.advancedFields}
|
||||||
uiSchema={effectiveUiSchema}
|
liveValidate={sectionConfig.liveValidate}
|
||||||
disabled={disabled || isSaving}
|
uiSchema={sectionConfig.uiSchema}
|
||||||
readonly={readonly}
|
disabled={disabled || isSaving}
|
||||||
showSubmit={false}
|
readonly={readonly}
|
||||||
i18nNamespace={configNamespace}
|
showSubmit={false}
|
||||||
customValidate={customValidate}
|
i18nNamespace={configNamespace}
|
||||||
formContext={{
|
customValidate={customValidate}
|
||||||
level: effectiveLevel,
|
formContext={{
|
||||||
cameraName,
|
level: effectiveLevel,
|
||||||
globalValue,
|
cameraName,
|
||||||
cameraValue,
|
globalValue,
|
||||||
hasChanges,
|
cameraValue,
|
||||||
extraHasChanges,
|
hasChanges,
|
||||||
setExtraHasChanges,
|
extraHasChanges,
|
||||||
overrides: uiOverrides as JsonValue | undefined,
|
setExtraHasChanges,
|
||||||
formData: currentFormData as ConfigSectionData,
|
overrides: uiOverrides as JsonValue | undefined,
|
||||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
formData: currentFormData as ConfigSectionData,
|
||||||
pendingDataBySection,
|
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||||
onPendingDataChange,
|
pendingDataBySection,
|
||||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
onPendingDataChange,
|
||||||
// For widgets that need access to full camera config (e.g., zone names)
|
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||||
fullCameraConfig:
|
// For widgets that need access to full camera config (e.g., zone names)
|
||||||
effectiveLevel === "camera" && cameraName
|
fullCameraConfig:
|
||||||
? config?.cameras?.[cameraName]
|
effectiveLevel === "camera" && cameraName
|
||||||
: undefined,
|
? config?.cameras?.[cameraName]
|
||||||
fullConfig: config,
|
: undefined,
|
||||||
// When rendering camera-level sections, provide the section path so
|
fullConfig: config,
|
||||||
// field templates can look up keys under the `config/cameras` namespace
|
// When rendering camera-level sections, provide the section path so
|
||||||
// When using a consolidated global namespace, keys are nested
|
// field templates can look up keys under the `config/cameras` namespace
|
||||||
// under the section name (e.g., `audio.label`) so provide the
|
// When using a consolidated global namespace, keys are nested
|
||||||
// section prefix to templates so they can attempt `${section}.${field}` lookups.
|
// under the section name (e.g., `audio.label`) so provide the
|
||||||
sectionI18nPrefix: sectionPath,
|
// section prefix to templates so they can attempt `${section}.${field}` lookups.
|
||||||
t,
|
sectionI18nPrefix: sectionPath,
|
||||||
renderers: wrappedRenderers,
|
t,
|
||||||
sectionDocs: sectionConfig.sectionDocs,
|
renderers: wrappedRenderers,
|
||||||
fieldDocs: sectionConfig.fieldDocs,
|
sectionDocs: sectionConfig.sectionDocs,
|
||||||
hiddenFields: effectiveHiddenFields,
|
fieldDocs: sectionConfig.fieldDocs,
|
||||||
restartRequired: sectionConfig.restartRequired,
|
hiddenFields: effectiveHiddenFields,
|
||||||
requiresRestart,
|
restartRequired: sectionConfig.restartRequired,
|
||||||
isProfile: !!profileName,
|
requiresRestart,
|
||||||
}}
|
isProfile: !!profileName,
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</FieldMessagesContext.Provider>
|
||||||
|
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -5,8 +5,9 @@ import {
|
|||||||
getUiOptions,
|
getUiOptions,
|
||||||
ADDITIONAL_PROPERTY_FLAG,
|
ADDITIONAL_PROPERTY_FLAG,
|
||||||
} from "@rjsf/utils";
|
} from "@rjsf/utils";
|
||||||
import { ComponentType, ReactNode } from "react";
|
import { ComponentType, ReactNode, useContext } from "react";
|
||||||
import { isValidElement } from "react";
|
import { isValidElement } from "react";
|
||||||
|
import { FieldMessagesContext } from "../../FieldMessagesContext";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -95,6 +96,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
"views/settings",
|
"views/settings",
|
||||||
]);
|
]);
|
||||||
const { getLocaleDocUrl } = useDocDomain();
|
const { getLocaleDocUrl } = useDocDomain();
|
||||||
|
const allFieldMessages = useContext(FieldMessagesContext);
|
||||||
|
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
return <div className="hidden">{children}</div>;
|
return <div className="hidden">{children}</div>;
|
||||||
@ -384,21 +386,15 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
|||||||
const beforeContent = renderCustom(beforeSpec);
|
const beforeContent = renderCustom(beforeSpec);
|
||||||
const afterContent = renderCustom(afterSpec);
|
const afterContent = renderCustom(afterSpec);
|
||||||
|
|
||||||
// Render conditional field messages from ui:messages
|
// Read field-level conditional messages from FieldMessagesContext
|
||||||
const fieldMessageSpecs = uiSchema?.["ui:messages"] as
|
const fieldPathStr = pathSegments.join(".");
|
||||||
| Array<{
|
const fieldMessageSpecs = allFieldMessages.filter(
|
||||||
key: string;
|
(m) => m.field === fieldPathStr,
|
||||||
messageKey: string;
|
);
|
||||||
severity: string;
|
const beforeMessages = fieldMessageSpecs.filter(
|
||||||
position?: string;
|
|
||||||
}>
|
|
||||||
| undefined;
|
|
||||||
const beforeMessages = fieldMessageSpecs?.filter(
|
|
||||||
(m) => (m.position ?? "before") === "before",
|
(m) => (m.position ?? "before") === "before",
|
||||||
);
|
);
|
||||||
const afterMessages = fieldMessageSpecs?.filter(
|
const afterMessages = fieldMessageSpecs.filter((m) => m.position === "after");
|
||||||
(m) => m.position === "after",
|
|
||||||
);
|
|
||||||
const beforeMessagesContent =
|
const beforeMessagesContent =
|
||||||
beforeMessages && beforeMessages.length > 0 ? (
|
beforeMessages && beforeMessages.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user