mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-11 02:47:37 +03:00
336 lines
9.0 KiB
TypeScript
336 lines
9.0 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;
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
}: 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 = 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) => {
|
|
onChange?.(e.formData);
|
|
},
|
|
[onChange],
|
|
);
|
|
|
|
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", 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;
|