frigate/web/src/components/config-form/ConfigForm.tsx
Josh Hawkins e7250f24cb
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 08:55:36 -07:00

371 lines
9.9 KiB
TypeScript

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