mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 18:43:09 +03:00
* 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
371 lines
9.9 KiB
TypeScript
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;
|