frigate/web/src/components/config-form/ConfigForm.tsx

371 lines
9.9 KiB
TypeScript
Raw Normal View History

Full UI configuration (#22151) * use react-jsonschema-form for UI config * don't use properties wrapper when generating config i18n json * configure for full i18n support * section fields * add descriptions to all fields for i18n * motion i18n * fix nullable fields * sanitize internal fields * add switches widgets and use friendly names * fix nullable schema entries * ensure update_topic is added to api calls this needs further backend implementation to work correctly * add global sections, camera config overrides, and reset button * i18n * add reset logic to global config view * tweaks * fix sections and live validation * fix validation for schema objects that can be null * generic and custom per-field validation * improve generic error validation messages * remove show advanced fields switch * tweaks * use shadcn theme * fix array field template * i18n tweaks * remove collapsible around root section * deep merge schema for advanced fields * add array field item template and fix ffmpeg section * add missing i18n keys * tweaks * comment out api call for testing * add config groups as a separate i18n namespace * add descriptions to all pydantic fields * make titles more concise * new titles as i18n * update i18n config generation script to use json schema * tweaks * tweaks * rebase * clean up * form tweaks * add wildcards and fix object filter fields * add field template for additionalproperties schema objects * improve typing * add section description from schema and clarify global vs camera level descriptions * separate and consolidate global and camera i18n namespaces * clean up now obsolete namespaces * tweaks * refactor sections and overrides * add ability to render components before and after fields * fix titles * chore(sections): remove legacy single-section components replaced by template * refactor configs to use individual files with a template * fix review description * apply hidden fields after ui schema * move util * remove unused i18n * clean up error messages * fix fast refresh * add custom validation and use it for ffmpeg input roles * update nav tree * remove unused * re-add override and modified indicators * mark pending changes and add confirmation dialog for resets * fix red unsaved dot * tweaks * add docs links, readonly keys, and restart required per field * add special case and comments for global motion section * add section form special cases * combine review sections * tweaks * add audio labels endpoint * add audio label switches and input to filter list * fix type * remove key from config when resetting to default/global * don't show description for new key/val fields * tweaks * spacing tweaks * add activity indicator and scrollbar tweaks * add docs to filter fields * wording changes * fix global ffmpeg section * add review classification zones to review form * add backend endpoint and frontend widget for ffmpeg presets and manual args * improve wording * hide descriptions for additional properties arrays * add warning log about incorrectly nested model config * spacing and language tweaks * fix i18n keys * networking section docs and description * small wording tweaks * add layout grid field * refactor with shared utilities * field order * add individual detectors to schema add detector titles and descriptions (docstrings in pydantic are used for descriptions) and add i18n keys to globals * clean up detectors section and i18n * don't save model config back to yaml when saving detectors * add full detectors config to api model dump works around the way we use detector plugins so we can have the full detector config for the frontend * add restart button to toast when restart is required * add ui option to remove inner cards * fix buttons * section tweaks * don't zoom into text on mobile * make buttons sticky at bottom of sections * small tweaks * highlight label of changed fields * add null to enum list when unwrapping * refactor to shared utils and add save all button * add undo all button * add RJSF to dictionary * consolidate utils * preserve form data when changing cameras * add mono fonts * add popover to show what fields will be saved * fix mobile menu not re-rendering with unsaved dots * tweaks * fix logger and env vars config section saving use escaped periods in keys to retain them in the config file (eg "frigate.embeddings") * add timezone widget * role map field with validation * fix validation for model section * add another hidden field * add footer message for required restart * use rjsf for notifications view * fix config saving * add replace rules field * default column layout and add field sizing * clean up field template * refactor profile settings to match rjsf forms * tweaks * refactor frigate+ view and make tweaks to sections * show frigate+ model info in detection model settings when using a frigate+ model * update restartRequired for all fields * fix restart fields * tweaks and add ability enable disabled cameras more backend changes required * require restart when enabling camera that is disabled in config * disable save when form is invalid * refactor ffmpeg section for readability * change label * clean up camera inputs fields * misc tweaks to ffmpeg section - add raw paths endpoint to ensure credentials get saved - restart required tooltip * maintenance settings tweaks * don't mutate with lodash * fix description re-rendering for nullable object fields * hide reindex field * update rjsf * add frigate+ description to settings pane * disable save all when any section is invalid * show translated field name in validation error pane * clean up * remove unused * fix genai merge * fix genai
2026-02-27 18:55:36 +03:00
// ConfigForm - Main RJSF form wrapper component
import Form from "@rjsf/shadcn";
import validator from "@rjsf/validator-ajv8";
import type { FormValidation, RJSFSchema, UiSchema } from "@rjsf/utils";
import type { IChangeEvent } from "@rjsf/core";
import { frigateTheme } from "./theme";
import { transformSchema } from "@/lib/config-schema";
import { createErrorTransformer } from "@/lib/config-schema/errorMessages";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { cn, mergeUiSchema } from "@/lib/utils";
import type { ConfigFormContext } from "@/types/configForm";
type SchemaWithProperties = RJSFSchema & {
properties: Record<string, RJSFSchema>;
};
type SchemaWithAdditionalProperties = RJSFSchema & {
additionalProperties: RJSFSchema;
};
// Runtime guards for schema fragments
const hasSchemaProperties = (
schema: RJSFSchema,
): schema is SchemaWithProperties =>
typeof schema === "object" &&
schema !== null &&
typeof schema.properties === "object" &&
schema.properties !== null;
const hasSchemaAdditionalProperties = (
schema: RJSFSchema,
): schema is SchemaWithAdditionalProperties =>
typeof schema === "object" &&
schema !== null &&
typeof schema.additionalProperties === "object" &&
schema.additionalProperties !== null;
// Detects path-style uiSchema keys (e.g., "filters.*.mask")
const isPathKey = (key: string) => key.includes(".") || key.includes("*");
type UiSchemaPathOverride = {
path: string[];
value: UiSchema;
};
// Split uiSchema into normal keys vs path-based overrides
const splitUiSchemaOverrides = (
uiSchema?: UiSchema,
): { baseUiSchema?: UiSchema; pathOverrides: UiSchemaPathOverride[] } => {
if (!uiSchema) {
return { baseUiSchema: undefined, pathOverrides: [] };
}
const baseUiSchema: UiSchema = {};
const pathOverrides: UiSchemaPathOverride[] = [];
Object.entries(uiSchema).forEach(([key, value]) => {
if (isPathKey(key)) {
pathOverrides.push({
path: key.split("."),
value: value as UiSchema,
});
} else {
baseUiSchema[key] = value as UiSchema;
}
});
return { baseUiSchema, pathOverrides };
};
// Apply wildcard path overrides to uiSchema using the schema structure
const applyUiSchemaPathOverrides = (
uiSchema: UiSchema,
schema: RJSFSchema,
overrides: UiSchemaPathOverride[],
): UiSchema => {
if (overrides.length === 0) {
return uiSchema;
}
// Recursively apply a path override; supports "*" to match any property.
const applyOverride = (
targetUi: UiSchema,
targetSchema: RJSFSchema,
path: string[],
value: UiSchema,
) => {
if (path.length === 0) {
Object.assign(targetUi, mergeUiSchema(targetUi, value));
return;
}
const [segment, ...rest] = path;
const schemaObj = targetSchema;
if (segment === "*") {
if (hasSchemaProperties(schemaObj)) {
Object.entries(schemaObj.properties).forEach(
([propertyName, propertySchema]) => {
const existing =
(targetUi[propertyName] as UiSchema | undefined) || {};
targetUi[propertyName] = { ...existing };
applyOverride(
targetUi[propertyName] as UiSchema,
propertySchema,
rest,
value,
);
},
);
} else if (hasSchemaAdditionalProperties(schemaObj)) {
// For dict schemas, apply override to additionalProperties
const existing =
(targetUi.additionalProperties as UiSchema | undefined) || {};
targetUi.additionalProperties = { ...existing };
applyOverride(
targetUi.additionalProperties as UiSchema,
schemaObj.additionalProperties,
rest,
value,
);
}
return;
}
if (hasSchemaProperties(schemaObj)) {
const propertySchema = schemaObj.properties[segment];
if (propertySchema) {
const existing = (targetUi[segment] as UiSchema | undefined) || {};
targetUi[segment] = { ...existing };
applyOverride(
targetUi[segment] as UiSchema,
propertySchema,
rest,
value,
);
}
}
};
const updated = { ...uiSchema };
overrides.forEach(({ path, value }) => {
applyOverride(updated, schema, path, value);
});
return updated;
};
const applyLayoutGridFieldDefaults = (uiSchema: UiSchema): UiSchema => {
const applyDefaults = (node: unknown): unknown => {
if (Array.isArray(node)) {
return node.map((item) => applyDefaults(item));
}
if (typeof node !== "object" || node === null) {
return node;
}
const nextNode: Record<string, unknown> = {};
Object.entries(node).forEach(([key, value]) => {
nextNode[key] = applyDefaults(value);
});
if (
Array.isArray(nextNode["ui:layoutGrid"]) &&
nextNode["ui:field"] === undefined
) {
nextNode["ui:field"] = "LayoutGridField";
}
return nextNode;
};
return applyDefaults(uiSchema) as UiSchema;
};
export interface ConfigFormProps {
/** JSON Schema for the form */
schema: RJSFSchema;
/** Current form data */
formData?: unknown;
/** Called when form data changes */
onChange?: (data: unknown) => void;
/** Called when form is submitted */
onSubmit?: (data: unknown) => void;
/** Called when form has errors on submit */
onError?: (errors: unknown[]) => void;
/** Additional uiSchema overrides */
uiSchema?: UiSchema;
/** Field ordering */
fieldOrder?: string[];
/** Field groups for layout */
fieldGroups?: Record<string, string[]>;
/** Fields to hide */
hiddenFields?: string[];
/** Fields marked as advanced (collapsed by default) */
advancedFields?: string[];
/** Whether form is disabled */
disabled?: boolean;
/** Whether form is read-only */
readonly?: boolean;
/** Whether to show submit button */
showSubmit?: boolean;
/** Custom class name */
className?: string;
/** Live validation mode */
liveValidate?: boolean;
/** Form context passed to all widgets */
formContext?: ConfigFormContext;
/** i18n namespace for field labels */
i18nNamespace?: string;
/** Optional custom validation */
customValidate?: (
formData: unknown,
errors: FormValidation,
) => FormValidation;
/** Called whenever form validation state changes */
onValidationChange?: (hasErrors: boolean) => void;
}
export function ConfigForm({
schema,
formData,
onChange,
onSubmit,
onError,
uiSchema: customUiSchema,
fieldOrder,
fieldGroups,
hiddenFields,
advancedFields,
disabled = false,
readonly = false,
showSubmit = false,
className,
liveValidate = true,
formContext,
i18nNamespace,
customValidate,
onValidationChange,
}: ConfigFormProps) {
const { t, i18n } = useTranslation([
i18nNamespace || "common",
"views/settings",
"config/validation",
]);
// Determine which fields to hide based on advanced toggle
const effectiveHiddenFields = useMemo(() => {
return hiddenFields;
}, [hiddenFields]);
// Transform schema and generate uiSchema
const { schema: transformedSchema, uiSchema: generatedUiSchema } = useMemo(
() =>
transformSchema(schema, {
fieldOrder,
hiddenFields: effectiveHiddenFields,
advancedFields: advancedFields,
i18nNamespace,
}),
[schema, fieldOrder, effectiveHiddenFields, advancedFields, i18nNamespace],
);
const { baseUiSchema, pathOverrides } = useMemo(
() => splitUiSchemaOverrides(customUiSchema),
[customUiSchema],
);
// Merge generated uiSchema with custom overrides
const finalUiSchema = useMemo(() => {
// Start with generated schema
const expandedUiSchema = applyUiSchemaPathOverrides(
generatedUiSchema,
transformedSchema,
pathOverrides,
);
const merged = applyLayoutGridFieldDefaults(
mergeUiSchema(expandedUiSchema, baseUiSchema),
);
// Add field groups
if (fieldGroups) {
merged["ui:groups"] = fieldGroups;
}
// Set submit button options
merged["ui:submitButtonOptions"] = showSubmit
? { norender: false }
: { norender: true };
// Ensure hiddenFields take precedence over any custom uiSchema overrides
// Build path-based overrides for hidden fields and apply them after merging
if (hiddenFields && hiddenFields.length > 0) {
const hiddenOverrides = hiddenFields.map((field) => ({
path: field.split("."),
value: { "ui:widget": "hidden" } as UiSchema,
}));
return applyUiSchemaPathOverrides(
merged,
transformedSchema,
hiddenOverrides,
);
}
return merged;
}, [
generatedUiSchema,
transformedSchema,
pathOverrides,
baseUiSchema,
showSubmit,
fieldGroups,
hiddenFields,
]);
// Create error transformer for user-friendly error messages
const errorTransformer = useMemo(() => createErrorTransformer(i18n), [i18n]);
const handleChange = useCallback(
(e: IChangeEvent) => {
onValidationChange?.(Array.isArray(e.errors) && e.errors.length > 0);
onChange?.(e.formData);
},
[onChange, onValidationChange],
);
const handleSubmit = useCallback(
(e: IChangeEvent) => {
onSubmit?.(e.formData);
},
[onSubmit],
);
// Extended form context with i18n info
const extendedFormContext = useMemo(
() => ({
...formContext,
i18nNamespace,
t,
}),
[formContext, i18nNamespace, t],
);
return (
<div className={cn("config-form w-full max-w-5xl", className)}>
<Form
schema={transformedSchema}
uiSchema={finalUiSchema}
formData={formData}
validator={validator}
onChange={handleChange}
onSubmit={handleSubmit}
onError={onError}
disabled={disabled}
readonly={readonly}
liveValidate={liveValidate}
formContext={extendedFormContext}
transformErrors={errorTransformer}
customValidate={customValidate}
{...frigateTheme}
/>
</div>
);
}
export default ConfigForm;