mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-28 23:06:13 +02:00
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
This commit is contained in:
115
web/src/lib/config-schema/errorMessages.ts
Normal file
115
web/src/lib/config-schema/errorMessages.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Custom error messages for RJSF validation
|
||||
// Maps JSON Schema validation keywords to user-friendly messages
|
||||
|
||||
import type { ErrorTransformer } from "@rjsf/utils";
|
||||
import type { i18n as I18n } from "i18next";
|
||||
|
||||
/**
|
||||
* Creates an error transformer function for RJSF
|
||||
* Transforms technical JSON Schema errors into user-friendly messages
|
||||
*/
|
||||
export function createErrorTransformer(i18n: I18n): ErrorTransformer {
|
||||
const t = i18n.t.bind(i18n);
|
||||
|
||||
const normalizeParams = (
|
||||
params: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> => {
|
||||
if (!params) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const allowedValues = params.allowedValues as unknown;
|
||||
|
||||
return {
|
||||
...params,
|
||||
allowedValues: Array.isArray(allowedValues)
|
||||
? allowedValues.join(", ")
|
||||
: allowedValues,
|
||||
};
|
||||
};
|
||||
|
||||
const getFieldPathFromProperty = (
|
||||
property: string | undefined,
|
||||
params: Record<string, unknown>,
|
||||
errorType: string,
|
||||
): string => {
|
||||
const basePath = (property || "").replace(/^\./, "").trim();
|
||||
const missingProperty = params.missingProperty as string | undefined;
|
||||
|
||||
if (errorType === "required" && missingProperty) {
|
||||
return basePath ? `${basePath}.${missingProperty}` : missingProperty;
|
||||
}
|
||||
|
||||
return basePath;
|
||||
};
|
||||
|
||||
return (errors) =>
|
||||
errors.map((error) => {
|
||||
const errorType = error.name || "";
|
||||
if (!errorType) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const normalizedParams = normalizeParams(error.params);
|
||||
const fieldPath = getFieldPathFromProperty(
|
||||
error.property,
|
||||
normalizedParams,
|
||||
errorType,
|
||||
);
|
||||
|
||||
let message: string | undefined;
|
||||
|
||||
// Try field-specific validation message first
|
||||
if (fieldPath) {
|
||||
const fieldKey = `${fieldPath}.validation.${errorType}`;
|
||||
if (i18n.exists(fieldKey)) {
|
||||
message = t(fieldKey, normalizedParams);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to generic validation message
|
||||
if (!message) {
|
||||
const genericKey = errorType;
|
||||
if (i18n.exists(genericKey, { ns: "config/validation" })) {
|
||||
message = t(genericKey, {
|
||||
...normalizedParams,
|
||||
ns: ["config/validation"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return {
|
||||
...error,
|
||||
message,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts field path from a Pydantic validation error location
|
||||
*/
|
||||
export function extractFieldPath(loc: (string | number)[]): string {
|
||||
// Skip the first element if it's 'body' (FastAPI adds this)
|
||||
const startIndex = loc[0] === "body" ? 1 : 0;
|
||||
return loc.slice(startIndex).join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms Pydantic validation errors into RJSF-compatible errors
|
||||
*/
|
||||
export function transformPydanticErrors(
|
||||
pydanticErrors: Array<{
|
||||
loc: (string | number)[];
|
||||
msg: string;
|
||||
type: string;
|
||||
}>,
|
||||
): Array<{ property: string; message: string }> {
|
||||
return pydanticErrors.map((error) => ({
|
||||
property: extractFieldPath(error.loc),
|
||||
message: error.msg,
|
||||
}));
|
||||
}
|
||||
17
web/src/lib/config-schema/index.ts
Normal file
17
web/src/lib/config-schema/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Config Schema Utilities
|
||||
// This module provides utilities for working with Frigate's JSON Schema
|
||||
|
||||
export {
|
||||
transformSchema,
|
||||
resolveSchemaRefs,
|
||||
resolveAndCleanSchema,
|
||||
extractSchemaSection,
|
||||
applySchemaDefaults,
|
||||
} from "./transformer";
|
||||
export type { TransformedSchema, UiSchemaOptions } from "./transformer";
|
||||
|
||||
export {
|
||||
createErrorTransformer,
|
||||
transformPydanticErrors,
|
||||
extractFieldPath,
|
||||
} from "./errorMessages";
|
||||
682
web/src/lib/config-schema/transformer.ts
Normal file
682
web/src/lib/config-schema/transformer.ts
Normal file
@@ -0,0 +1,682 @@
|
||||
// Schema Transformer
|
||||
// Converts Pydantic-generated JSON Schema to RJSF-compatible format with uiSchema
|
||||
|
||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
|
||||
export interface TransformedSchema {
|
||||
schema: RJSFSchema;
|
||||
uiSchema: UiSchema;
|
||||
}
|
||||
|
||||
export interface UiSchemaOptions {
|
||||
/** Field ordering for the schema */
|
||||
fieldOrder?: string[];
|
||||
/** Fields to hide from the form */
|
||||
hiddenFields?: string[];
|
||||
/** Fields to mark as advanced (collapsed by default) */
|
||||
advancedFields?: string[];
|
||||
/** Custom widget mappings */
|
||||
widgetMappings?: Record<string, string>;
|
||||
/** Whether to include descriptions */
|
||||
includeDescriptions?: boolean;
|
||||
/** i18n namespace for field labels (e.g., "config/detect") */
|
||||
i18nNamespace?: string;
|
||||
}
|
||||
|
||||
// Type guard for schema objects
|
||||
function isSchemaObject(
|
||||
schema: unknown,
|
||||
): schema is RJSFSchema & Record<string, unknown> {
|
||||
return typeof schema === "object" && schema !== null;
|
||||
}
|
||||
|
||||
function schemaHasType(schema: Record<string, unknown>, type: string): boolean {
|
||||
const schemaType = schema.type;
|
||||
if (Array.isArray(schemaType)) {
|
||||
return schemaType.includes(type);
|
||||
}
|
||||
return schemaType === type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes nullable schemas by unwrapping anyOf/oneOf [Type, null] patterns.
|
||||
*
|
||||
* When Pydantic generates JSON Schema for optional fields (e.g., Optional[int]),
|
||||
* it creates anyOf/oneOf unions like: [{ type: "integer", ... }, { type: "null" }]
|
||||
*
|
||||
* This causes RJSF to treat the field as a multi-schema field with a dropdown selector,
|
||||
* which leads to the field disappearing when the value is cleared (becomes undefined/null).
|
||||
*
|
||||
* This function unwraps these simple nullable patterns to a single non-null schema,
|
||||
* allowing fields to remain visible and functional even when empty.
|
||||
*
|
||||
* @example
|
||||
* // Input: { anyOf: [{ type: "integer" }, { type: "null" }] }
|
||||
* // Output: { type: "integer" }
|
||||
*
|
||||
* @example
|
||||
* // Input: { oneOf: [{ type: "string" }, { type: "null" }] }
|
||||
* // Output: { type: "string" }
|
||||
*/
|
||||
function normalizeNullableSchema(schema: RJSFSchema): RJSFSchema {
|
||||
if (!isSchemaObject(schema)) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
schemaObj.default === null &&
|
||||
schemaObj.type &&
|
||||
!Array.isArray(schemaObj.type) &&
|
||||
schemaObj.type !== "null"
|
||||
) {
|
||||
schemaObj.type = [schemaObj.type, "null"];
|
||||
}
|
||||
|
||||
const anyOf = schemaObj.anyOf;
|
||||
if (Array.isArray(anyOf)) {
|
||||
const hasNull = anyOf.some(
|
||||
(item) =>
|
||||
isSchemaObject(item) &&
|
||||
(item as Record<string, unknown>).type === "null",
|
||||
);
|
||||
const nonNull = anyOf.find(
|
||||
(item) =>
|
||||
isSchemaObject(item) &&
|
||||
(item as Record<string, unknown>).type !== "null",
|
||||
) as RJSFSchema | undefined;
|
||||
|
||||
if (hasNull && nonNull && anyOf.length === 2) {
|
||||
const normalizedNonNull = normalizeNullableSchema(nonNull as RJSFSchema);
|
||||
const normalizedNonNullObj = normalizedNonNull as Record<string, unknown>;
|
||||
const nonNullType = normalizedNonNullObj.type;
|
||||
const mergedType = Array.isArray(nonNullType)
|
||||
? Array.from(new Set([...nonNullType, "null"]))
|
||||
: nonNullType
|
||||
? [nonNullType, "null"]
|
||||
: ["null"];
|
||||
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
|
||||
const merged: Record<string, unknown> = {
|
||||
...rest,
|
||||
...normalizedNonNullObj,
|
||||
type: mergedType,
|
||||
};
|
||||
// When unwrapping a nullable enum, add null to the enum list so
|
||||
// JSON Schema validation accepts the null default value.
|
||||
if (Array.isArray(merged.enum)) {
|
||||
merged.enum = [...(merged.enum as unknown[]), null];
|
||||
}
|
||||
return merged as RJSFSchema;
|
||||
}
|
||||
|
||||
return {
|
||||
...schemaObj,
|
||||
anyOf: anyOf
|
||||
.filter(isSchemaObject)
|
||||
.map((item) => normalizeNullableSchema(item as RJSFSchema)),
|
||||
} as RJSFSchema;
|
||||
}
|
||||
|
||||
const oneOf = schemaObj.oneOf;
|
||||
if (Array.isArray(oneOf)) {
|
||||
const hasNull = oneOf.some(
|
||||
(item) =>
|
||||
isSchemaObject(item) &&
|
||||
(item as Record<string, unknown>).type === "null",
|
||||
);
|
||||
const nonNull = oneOf.find(
|
||||
(item) =>
|
||||
isSchemaObject(item) &&
|
||||
(item as Record<string, unknown>).type !== "null",
|
||||
) as RJSFSchema | undefined;
|
||||
|
||||
if (hasNull && nonNull && oneOf.length === 2) {
|
||||
const normalizedNonNull = normalizeNullableSchema(nonNull as RJSFSchema);
|
||||
const normalizedNonNullObj = normalizedNonNull as Record<string, unknown>;
|
||||
const nonNullType = normalizedNonNullObj.type;
|
||||
const mergedType = Array.isArray(nonNullType)
|
||||
? Array.from(new Set([...nonNullType, "null"]))
|
||||
: nonNullType
|
||||
? [nonNullType, "null"]
|
||||
: ["null"];
|
||||
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schemaObj;
|
||||
const merged: Record<string, unknown> = {
|
||||
...rest,
|
||||
...normalizedNonNullObj,
|
||||
type: mergedType,
|
||||
};
|
||||
// When unwrapping a nullable oneOf enum, add null to the enum list.
|
||||
if (Array.isArray(merged.enum)) {
|
||||
merged.enum = [...(merged.enum as unknown[]), null];
|
||||
}
|
||||
return merged as RJSFSchema;
|
||||
}
|
||||
|
||||
return {
|
||||
...schemaObj,
|
||||
oneOf: oneOf
|
||||
.filter(isSchemaObject)
|
||||
.map((item) => normalizeNullableSchema(item as RJSFSchema)),
|
||||
} as RJSFSchema;
|
||||
}
|
||||
|
||||
if (isSchemaObject(schemaObj.properties)) {
|
||||
const normalizedProps: Record<string, RJSFSchema> = {};
|
||||
for (const [key, prop] of Object.entries(
|
||||
schemaObj.properties as Record<string, unknown>,
|
||||
)) {
|
||||
if (isSchemaObject(prop)) {
|
||||
normalizedProps[key] = normalizeNullableSchema(prop as RJSFSchema);
|
||||
}
|
||||
}
|
||||
return { ...schemaObj, properties: normalizedProps } as RJSFSchema;
|
||||
}
|
||||
|
||||
if (schemaObj.items) {
|
||||
if (Array.isArray(schemaObj.items)) {
|
||||
return {
|
||||
...schemaObj,
|
||||
items: schemaObj.items
|
||||
.filter(isSchemaObject)
|
||||
.map((item) => normalizeNullableSchema(item as RJSFSchema)),
|
||||
} as RJSFSchema;
|
||||
} else if (isSchemaObject(schemaObj.items)) {
|
||||
return {
|
||||
...schemaObj,
|
||||
items: normalizeNullableSchema(schemaObj.items as RJSFSchema),
|
||||
} as RJSFSchema;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
schemaObj.additionalProperties &&
|
||||
isSchemaObject(schemaObj.additionalProperties)
|
||||
) {
|
||||
return {
|
||||
...schemaObj,
|
||||
additionalProperties: normalizeNullableSchema(
|
||||
schemaObj.additionalProperties as RJSFSchema,
|
||||
),
|
||||
} as RJSFSchema;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves $ref references in a JSON Schema
|
||||
* This converts Pydantic's $defs-based schema to inline schemas
|
||||
*/
|
||||
export function resolveSchemaRefs(
|
||||
schema: RJSFSchema,
|
||||
rootSchema?: RJSFSchema,
|
||||
): RJSFSchema {
|
||||
const root = rootSchema || schema;
|
||||
const defs =
|
||||
(root as Record<string, unknown>).$defs ||
|
||||
(root as Record<string, unknown>).definitions ||
|
||||
{};
|
||||
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
|
||||
// Handle $ref
|
||||
if (schemaObj.$ref && typeof schemaObj.$ref === "string") {
|
||||
const refPath = schemaObj.$ref
|
||||
.replace(/^#\/\$defs\//, "")
|
||||
.replace(/^#\/definitions\//, "");
|
||||
const resolved = (defs as Record<string, unknown>)[refPath];
|
||||
if (isSchemaObject(resolved)) {
|
||||
// Merge any additional properties from the original schema
|
||||
const { $ref: _ref, ...rest } = schemaObj;
|
||||
return resolveSchemaRefs({ ...resolved, ...rest } as RJSFSchema, root);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
// Handle allOf (Pydantic uses this for inheritance)
|
||||
if (Array.isArray(schemaObj.allOf)) {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const subSchema of schemaObj.allOf) {
|
||||
if (isSchemaObject(subSchema)) {
|
||||
const resolved = resolveSchemaRefs(subSchema as RJSFSchema, root);
|
||||
Object.assign(merged, resolved);
|
||||
if (
|
||||
isSchemaObject(resolved) &&
|
||||
(resolved as Record<string, unknown>).properties
|
||||
) {
|
||||
merged.properties = {
|
||||
...(merged.properties as object),
|
||||
...((resolved as Record<string, unknown>).properties as object),
|
||||
};
|
||||
}
|
||||
if (
|
||||
isSchemaObject(resolved) &&
|
||||
Array.isArray((resolved as Record<string, unknown>).required)
|
||||
) {
|
||||
merged.required = [
|
||||
...((merged.required as string[]) || []),
|
||||
...((resolved as Record<string, unknown>).required as string[]),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Include any extra properties from the parent schema
|
||||
const { allOf: _allOf, ...rest } = schemaObj;
|
||||
return { ...merged, ...rest } as RJSFSchema;
|
||||
}
|
||||
|
||||
// Handle anyOf/oneOf
|
||||
if (Array.isArray(schemaObj.anyOf)) {
|
||||
return {
|
||||
...schemaObj,
|
||||
anyOf: schemaObj.anyOf
|
||||
.filter(isSchemaObject)
|
||||
.map((s) => resolveSchemaRefs(s as RJSFSchema, root)),
|
||||
} as RJSFSchema;
|
||||
}
|
||||
if (Array.isArray(schemaObj.oneOf)) {
|
||||
return {
|
||||
...schemaObj,
|
||||
oneOf: schemaObj.oneOf
|
||||
.filter(isSchemaObject)
|
||||
.map((s) => resolveSchemaRefs(s as RJSFSchema, root)),
|
||||
} as RJSFSchema;
|
||||
}
|
||||
|
||||
// Handle properties
|
||||
if (isSchemaObject(schemaObj.properties)) {
|
||||
const resolvedProps: Record<string, RJSFSchema> = {};
|
||||
for (const [key, prop] of Object.entries(
|
||||
schemaObj.properties as Record<string, unknown>,
|
||||
)) {
|
||||
if (isSchemaObject(prop)) {
|
||||
resolvedProps[key] = resolveSchemaRefs(prop as RJSFSchema, root);
|
||||
}
|
||||
}
|
||||
return { ...schemaObj, properties: resolvedProps } as RJSFSchema;
|
||||
}
|
||||
|
||||
// Handle items (for arrays)
|
||||
if (schemaObj.items) {
|
||||
if (Array.isArray(schemaObj.items)) {
|
||||
return {
|
||||
...schemaObj,
|
||||
items: schemaObj.items
|
||||
.filter(isSchemaObject)
|
||||
.map((item) => resolveSchemaRefs(item as RJSFSchema, root)),
|
||||
} as RJSFSchema;
|
||||
} else if (isSchemaObject(schemaObj.items)) {
|
||||
return {
|
||||
...schemaObj,
|
||||
items: resolveSchemaRefs(schemaObj.items as RJSFSchema, root),
|
||||
} as RJSFSchema;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle additionalProperties (for dicts)
|
||||
if (
|
||||
schemaObj.additionalProperties &&
|
||||
isSchemaObject(schemaObj.additionalProperties)
|
||||
) {
|
||||
return {
|
||||
...schemaObj,
|
||||
additionalProperties: resolveSchemaRefs(
|
||||
schemaObj.additionalProperties as RJSFSchema,
|
||||
root,
|
||||
),
|
||||
} as RJSFSchema;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that resolves refs and strips $defs from result
|
||||
* Use this as the main entry point for resolving schemas
|
||||
*/
|
||||
export function resolveAndCleanSchema(schema: RJSFSchema): RJSFSchema {
|
||||
const resolved = resolveSchemaRefs(schema);
|
||||
// Remove $defs from result - they're no longer needed after resolution
|
||||
const {
|
||||
$defs: _defs,
|
||||
definitions: _definitions,
|
||||
...cleanSchema
|
||||
} = resolved as Record<string, unknown>;
|
||||
return cleanSchema as RJSFSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the appropriate widget for a schema field
|
||||
*/
|
||||
function getWidgetForField(
|
||||
fieldName: string,
|
||||
fieldSchema: RJSFSchema,
|
||||
customMappings?: Record<string, string>,
|
||||
): string | undefined {
|
||||
// Check custom mappings first
|
||||
if (customMappings?.[fieldName]) {
|
||||
return customMappings[fieldName];
|
||||
}
|
||||
|
||||
const schemaObj = fieldSchema as Record<string, unknown>;
|
||||
|
||||
// Password fields
|
||||
if (
|
||||
fieldName.toLowerCase().includes("password") ||
|
||||
fieldName.toLowerCase().includes("secret")
|
||||
) {
|
||||
return "password";
|
||||
}
|
||||
|
||||
// Color fields
|
||||
if (
|
||||
fieldName.toLowerCase().includes("color") &&
|
||||
schemaHasType(schemaObj, "object")
|
||||
) {
|
||||
return "color";
|
||||
}
|
||||
|
||||
// Enum fields get select widget
|
||||
if (schemaObj.enum) {
|
||||
return "select";
|
||||
}
|
||||
|
||||
// Boolean fields get switch widget
|
||||
if (schemaHasType(schemaObj, "boolean")) {
|
||||
return "switch";
|
||||
}
|
||||
|
||||
// Number with range gets slider
|
||||
if (
|
||||
(schemaHasType(schemaObj, "number") ||
|
||||
schemaHasType(schemaObj, "integer")) &&
|
||||
schemaObj.minimum !== undefined &&
|
||||
schemaObj.maximum !== undefined
|
||||
) {
|
||||
return "range";
|
||||
}
|
||||
|
||||
// Array of strings gets tags widget
|
||||
if (
|
||||
schemaHasType(schemaObj, "array") &&
|
||||
isSchemaObject(schemaObj.items) &&
|
||||
(schemaObj.items as Record<string, unknown>).type === "string"
|
||||
) {
|
||||
return "tags";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a uiSchema for a given JSON Schema
|
||||
*/
|
||||
function generateUiSchema(
|
||||
schema: RJSFSchema,
|
||||
options: UiSchemaOptions = {},
|
||||
currentPath: string[] = [],
|
||||
): UiSchema {
|
||||
const uiSchema: UiSchema = {};
|
||||
const {
|
||||
fieldOrder,
|
||||
hiddenFields = [],
|
||||
advancedFields = [],
|
||||
widgetMappings = {},
|
||||
includeDescriptions = true,
|
||||
} = options;
|
||||
|
||||
// Pre-split patterns for wildcard matching ("*") on nested paths
|
||||
const hiddenFieldPatterns = hiddenFields.map((field) => field.split("."));
|
||||
const advancedFieldPatterns = advancedFields.map((field) => field.split("."));
|
||||
|
||||
// Match a concrete path to a wildcard pattern of equal length
|
||||
const matchesPathPattern = (path: string[], pattern: string[]) => {
|
||||
if (path.length !== pattern.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pattern.every((segment, index) => {
|
||||
if (segment === "*") {
|
||||
return true;
|
||||
}
|
||||
return segment === path[index];
|
||||
});
|
||||
};
|
||||
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
|
||||
// Set field ordering
|
||||
if (fieldOrder && fieldOrder.length > 0) {
|
||||
uiSchema["ui:order"] = [...fieldOrder, "*"];
|
||||
}
|
||||
|
||||
if (!isSchemaObject(schemaObj.properties)) {
|
||||
return uiSchema;
|
||||
}
|
||||
|
||||
for (const [fieldName, fieldSchema] of Object.entries(
|
||||
schemaObj.properties as Record<string, unknown>,
|
||||
)) {
|
||||
if (!isSchemaObject(fieldSchema)) continue;
|
||||
|
||||
const fSchema = fieldSchema as Record<string, unknown>;
|
||||
const fieldUiSchema: UiSchema = {};
|
||||
|
||||
// Track full path to support wildcard-based rules
|
||||
const fieldPath = [...currentPath, fieldName];
|
||||
|
||||
// Hidden fields
|
||||
if (
|
||||
hiddenFieldPatterns.some((pattern) =>
|
||||
matchesPathPattern(fieldPath, pattern),
|
||||
)
|
||||
) {
|
||||
fieldUiSchema["ui:widget"] = "hidden";
|
||||
uiSchema[fieldName] = fieldUiSchema;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Widget selection
|
||||
const widget = getWidgetForField(
|
||||
fieldName,
|
||||
fieldSchema as RJSFSchema,
|
||||
widgetMappings,
|
||||
);
|
||||
if (widget) {
|
||||
fieldUiSchema["ui:widget"] = widget;
|
||||
}
|
||||
|
||||
// Description
|
||||
if (!includeDescriptions && fSchema.description) {
|
||||
fieldUiSchema["ui:description"] = "";
|
||||
}
|
||||
|
||||
// Advanced fields - mark for collapsible
|
||||
if (
|
||||
advancedFieldPatterns.some((pattern) =>
|
||||
matchesPathPattern(fieldPath, pattern),
|
||||
)
|
||||
) {
|
||||
fieldUiSchema["ui:options"] = {
|
||||
...((fieldUiSchema["ui:options"] as object) || {}),
|
||||
advanced: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle nested objects recursively
|
||||
if (schemaHasType(fSchema, "object")) {
|
||||
if (isSchemaObject(fSchema.properties)) {
|
||||
Object.assign(
|
||||
fieldUiSchema,
|
||||
generateUiSchema(fieldSchema as RJSFSchema, options, fieldPath),
|
||||
);
|
||||
}
|
||||
|
||||
if (isSchemaObject(fSchema.additionalProperties)) {
|
||||
// For dict-like schemas (additionalProperties), use "*" for path matching
|
||||
const additionalSchema = generateUiSchema(
|
||||
fSchema.additionalProperties as RJSFSchema,
|
||||
options,
|
||||
[...fieldPath, "*"],
|
||||
);
|
||||
if (Object.keys(additionalSchema).length > 0) {
|
||||
fieldUiSchema.additionalProperties = additionalSchema;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(fieldUiSchema).length > 0) {
|
||||
uiSchema[fieldName] = fieldUiSchema;
|
||||
}
|
||||
}
|
||||
|
||||
return uiSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a Pydantic JSON Schema to RJSF format
|
||||
* Resolves references and generates appropriate uiSchema
|
||||
*/
|
||||
export function transformSchema(
|
||||
rawSchema: RJSFSchema,
|
||||
options: UiSchemaOptions = {},
|
||||
): TransformedSchema {
|
||||
// Resolve all $ref references and clean the result
|
||||
const cleanSchema = resolveAndCleanSchema(rawSchema);
|
||||
const normalizedSchema = normalizeNullableSchema(cleanSchema);
|
||||
|
||||
// Generate uiSchema
|
||||
const uiSchema = generateUiSchema(normalizedSchema, options);
|
||||
|
||||
return {
|
||||
schema: normalizedSchema,
|
||||
uiSchema,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a subsection of the schema by path
|
||||
* Useful for rendering individual config sections
|
||||
*/
|
||||
export function extractSchemaSection(
|
||||
schema: RJSFSchema,
|
||||
path: string,
|
||||
): RJSFSchema | null {
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
const defs = (schemaObj.$defs || schemaObj.definitions || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const parts = path.split(".");
|
||||
let current = schema as Record<string, unknown>;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!isSchemaObject(current.properties)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let propSchema = (current.properties as Record<string, unknown>)[
|
||||
part
|
||||
] as Record<string, unknown>;
|
||||
|
||||
if (!propSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve $ref if present
|
||||
if (propSchema.$ref && typeof propSchema.$ref === "string") {
|
||||
const refPath = (propSchema.$ref as string)
|
||||
.replace(/^#\/\$defs\//, "")
|
||||
.replace(/^#\/definitions\//, "");
|
||||
const resolved = defs[refPath] as Record<string, unknown>;
|
||||
if (resolved) {
|
||||
// Merge any additional properties from original
|
||||
const { $ref: _ref, ...rest } = propSchema;
|
||||
propSchema = { ...resolved, ...rest };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
current = propSchema;
|
||||
}
|
||||
|
||||
// Return section schema with $defs included for nested ref resolution
|
||||
const sectionWithDefs = {
|
||||
...current,
|
||||
$defs: defs,
|
||||
} as RJSFSchema;
|
||||
|
||||
// Resolve all nested refs and clean the result
|
||||
return resolveAndCleanSchema(sectionWithDefs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges default values from schema into form data.
|
||||
*
|
||||
* Handles anyOf/oneOf schemas (e.g., `anyOf: [MotionConfig, null]`) by
|
||||
* finding the non-null object branch and applying its property defaults.
|
||||
*/
|
||||
export function applySchemaDefaults(
|
||||
schema: RJSFSchema,
|
||||
formData: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
const result = { ...formData };
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
|
||||
// Resolve properties, falling back to the non-null object branch of
|
||||
// anyOf/oneOf schemas when top-level properties are not present.
|
||||
let properties = schemaObj.properties;
|
||||
if (!isSchemaObject(properties)) {
|
||||
const branches = (schemaObj.anyOf ?? schemaObj.oneOf) as
|
||||
| unknown[]
|
||||
| undefined;
|
||||
if (Array.isArray(branches)) {
|
||||
const objectBranch = branches.find(
|
||||
(s) =>
|
||||
isSchemaObject(s) &&
|
||||
(s as Record<string, unknown>).type !== "null" &&
|
||||
isSchemaObject((s as Record<string, unknown>).properties),
|
||||
) as Record<string, unknown> | undefined;
|
||||
if (objectBranch) {
|
||||
properties = objectBranch.properties;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSchemaObject(properties)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const [key, prop] of Object.entries(
|
||||
properties as Record<string, unknown>,
|
||||
)) {
|
||||
if (!isSchemaObject(prop)) continue;
|
||||
|
||||
const propSchema = prop as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
result[key] === undefined &&
|
||||
propSchema.default !== undefined &&
|
||||
propSchema.default !== null
|
||||
) {
|
||||
result[key] = propSchema.default;
|
||||
} else if (
|
||||
schemaHasType(propSchema, "object") &&
|
||||
isSchemaObject(propSchema.properties) &&
|
||||
result[key] !== undefined
|
||||
) {
|
||||
result[key] = applySchemaDefaults(
|
||||
prop as RJSFSchema,
|
||||
result[key] as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,6 +1,57 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { UiSchema } from "@rjsf/utils";
|
||||
import { JsonObject } from "@/types/configForm";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merges uiSchema objects, preserving nested properties from the base schema
|
||||
* when overrides don't explicitly replace them.
|
||||
*
|
||||
* Special handling for ui:options - merges nested options rather than replacing them.
|
||||
*/
|
||||
export function mergeUiSchema(
|
||||
base: UiSchema = {},
|
||||
overrides: UiSchema = {},
|
||||
): UiSchema {
|
||||
const result: UiSchema = { ...base };
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (
|
||||
key === "ui:options" &&
|
||||
base[key] &&
|
||||
typeof value === "object" &&
|
||||
value !== null
|
||||
) {
|
||||
// Merge ui:options objects instead of replacing
|
||||
result[key] = {
|
||||
...(typeof base[key] === "object" && base[key] !== null
|
||||
? base[key]
|
||||
: {}),
|
||||
...value,
|
||||
};
|
||||
} else if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
// Recursively merge nested objects (field configurations)
|
||||
result[key] = mergeUiSchema(base[key] as UiSchema, value as UiSchema);
|
||||
} else {
|
||||
// Replace primitive values and arrays
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a JsonObject (non-array object)
|
||||
*/
|
||||
export function isJsonObject(value: unknown): value is JsonObject {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user