use react-jsonschema-form for UI config

This commit is contained in:
Josh Hawkins
2026-02-27 09:37:56 -06:00
parent eeefbf2bb5
commit 68c74fef05
48 changed files with 4059 additions and 11 deletions
@@ -0,0 +1,157 @@
// ConfigForm - Main RJSF form wrapper component
import Form from "@rjsf/core";
import validator from "@rjsf/validator-ajv8";
import type { 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, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
export interface ConfigFormProps {
/** JSON Schema for the form */
schema: RJSFSchema;
/** Current form data */
formData?: Record<string, unknown>;
/** Called when form data changes */
onChange?: (data: Record<string, unknown>) => void;
/** Called when form is submitted */
onSubmit?: (data: Record<string, unknown>) => void;
/** Called when form has errors on submit */
onError?: (errors: unknown[]) => void;
/** Additional uiSchema overrides */
uiSchema?: UiSchema;
/** Field ordering */
fieldOrder?: 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?: Record<string, unknown>;
}
export function ConfigForm({
schema,
formData,
onChange,
onSubmit,
onError,
uiSchema: customUiSchema,
fieldOrder,
hiddenFields,
advancedFields,
disabled = false,
readonly = false,
showSubmit = true,
className,
liveValidate = false,
formContext,
}: ConfigFormProps) {
const { t } = useTranslation(["views/settings"]);
const [showAdvanced, setShowAdvanced] = useState(false);
// Determine which fields to hide based on advanced toggle
const effectiveHiddenFields = useMemo(() => {
if (showAdvanced || !advancedFields || advancedFields.length === 0) {
return hiddenFields;
}
// Hide advanced fields when toggle is off
return [...(hiddenFields || []), ...advancedFields];
}, [hiddenFields, advancedFields, showAdvanced]);
// Transform schema and generate uiSchema
const { schema: transformedSchema, uiSchema: generatedUiSchema } = useMemo(
() =>
transformSchema(schema, {
fieldOrder,
hiddenFields: effectiveHiddenFields,
advancedFields: showAdvanced ? advancedFields : [],
}),
[schema, fieldOrder, effectiveHiddenFields, advancedFields, showAdvanced],
);
// Merge generated uiSchema with custom overrides
const finalUiSchema = useMemo(
() => ({
...generatedUiSchema,
...customUiSchema,
"ui:submitButtonOptions": showSubmit
? { norender: false }
: { norender: true },
}),
[generatedUiSchema, customUiSchema, showSubmit],
);
// Create error transformer for user-friendly error messages
const errorTransformer = useMemo(() => createErrorTransformer(), []);
const handleChange = useCallback(
(e: IChangeEvent) => {
onChange?.(e.formData);
},
[onChange],
);
const handleSubmit = useCallback(
(e: IChangeEvent) => {
onSubmit?.(e.formData);
},
[onSubmit],
);
const hasAdvancedFields = advancedFields && advancedFields.length > 0;
return (
<div className={cn("config-form", className)}>
{hasAdvancedFields && (
<div className="mb-4 flex items-center justify-end gap-2">
<Switch
id="show-advanced"
checked={showAdvanced}
onCheckedChange={setShowAdvanced}
/>
<Label
htmlFor="show-advanced"
className="cursor-pointer text-sm text-muted-foreground"
>
{t("configForm.showAdvanced", {
defaultValue: "Show Advanced Settings",
})}
</Label>
</div>
)}
<Form
schema={transformedSchema}
uiSchema={finalUiSchema}
formData={formData}
validator={validator}
onChange={handleChange}
onSubmit={handleSubmit}
onError={onError}
disabled={disabled}
readonly={readonly}
liveValidate={liveValidate}
formContext={formContext}
transformErrors={errorTransformer}
{...frigateTheme}
/>
</div>
);
}
export default ConfigForm;
@@ -0,0 +1,30 @@
// Audio Section Component
// Reusable for both global and camera-level audio settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the audio section
export const audioSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"listen",
"filters",
"min_volume",
"max_not_heard",
"num_threads",
],
fieldGroups: {
detection: ["listen", "filters"],
sensitivity: ["min_volume", "max_not_heard"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["min_volume", "max_not_heard", "num_threads"],
};
export const AudioSection = createConfigSection({
sectionPath: "audio",
translationKey: "configForm.audio",
defaultConfig: audioSectionConfig,
});
export default AudioSection;
@@ -0,0 +1,249 @@
// Base Section Component for config form sections
// Used as a foundation for reusable section components
import { useMemo, useCallback } from "react";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { ConfigForm } from "../ConfigForm";
import { useConfigOverride } from "@/hooks/use-config-override";
import { useSectionSchema } from "@/hooks/use-config-schema";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LuRotateCcw } from "react-icons/lu";
import get from "lodash/get";
export interface SectionConfig {
/** Field ordering within the section */
fieldOrder?: string[];
/** Fields to group together */
fieldGroups?: Record<string, string[]>;
/** Fields to hide from UI */
hiddenFields?: string[];
/** Fields to show in advanced section */
advancedFields?: string[];
}
export interface BaseSectionProps {
/** Whether this is at global or camera level */
level: "global" | "camera";
/** Camera name (required if level is "camera") */
cameraName?: string;
/** Whether to show override indicator badge */
showOverrideIndicator?: boolean;
/** Custom section configuration */
sectionConfig?: SectionConfig;
/** Whether the section is disabled */
disabled?: boolean;
/** Whether the section is read-only */
readonly?: boolean;
/** Callback when settings are saved */
onSave?: () => void;
/** Whether a restart is required after changes */
requiresRestart?: boolean;
}
export interface CreateSectionOptions {
/** The config path for this section (e.g., "detect", "record") */
sectionPath: string;
/** Translation key prefix for this section */
translationKey: string;
/** Default section configuration */
defaultConfig: SectionConfig;
}
/**
* Factory function to create reusable config section components
*/
export function createConfigSection({
sectionPath,
translationKey,
defaultConfig,
}: CreateSectionOptions) {
return function ConfigSection({
level,
cameraName,
showOverrideIndicator = true,
sectionConfig = defaultConfig,
disabled = false,
readonly = false,
onSave,
requiresRestart = true,
}: BaseSectionProps) {
const { t } = useTranslation(["views/settings"]);
// Fetch config
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
// Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, level);
// Get override status
const { isOverridden, globalValue, cameraValue, resetToGlobal } =
useConfigOverride({
config,
cameraName: level === "camera" ? cameraName : undefined,
sectionPath,
});
// Get current form data
const formData = useMemo(() => {
if (!config) return {};
if (level === "camera" && cameraName) {
return get(config.cameras?.[cameraName], sectionPath) || {};
}
return get(config, sectionPath) || {};
}, [config, level, cameraName]);
// Handle form submission
const handleSubmit = useCallback(
async (data: Record<string, unknown>) => {
try {
const basePath =
level === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
await axios.put("config/set", {
requires_restart: requiresRestart ? 1 : 0,
config_data: {
[basePath]: data,
},
});
toast.success(
t(`${translationKey}.toast.success`, {
defaultValue: "Settings saved successfully",
}),
);
refreshConfig();
onSave?.();
} catch (error) {
// Parse Pydantic validation errors from API response
if (axios.isAxiosError(error) && error.response?.data) {
const responseData = error.response.data;
// Pydantic errors come as { detail: [...] } or { message: "..." }
if (responseData.detail && Array.isArray(responseData.detail)) {
const validationMessages = responseData.detail
.map((err: { loc?: string[]; msg?: string }) => {
const field = err.loc?.slice(1).join(".") || "unknown";
return `${field}: ${err.msg || "Invalid value"}`;
})
.join(", ");
toast.error(
t(`${translationKey}.toast.validationError`, {
defaultValue: `Validation failed: ${validationMessages}`,
}),
);
} else if (responseData.message) {
toast.error(responseData.message);
} else {
toast.error(
t(`${translationKey}.toast.error`, {
defaultValue: "Failed to save settings",
}),
);
}
} else {
toast.error(
t(`${translationKey}.toast.error`, {
defaultValue: "Failed to save settings",
}),
);
}
}
},
[level, cameraName, requiresRestart, t, refreshConfig, onSave],
);
// Handle reset to global
const handleResetToGlobal = useCallback(async () => {
if (level !== "camera" || !cameraName) return;
try {
const basePath = `cameras.${cameraName}.${sectionPath}`;
// Reset by setting to null/undefined or removing the override
await axios.put("config/set", {
requires_restart: requiresRestart ? 1 : 0,
config_data: {
[basePath]: resetToGlobal(),
},
});
toast.success(
t(`${translationKey}.toast.resetSuccess`, {
defaultValue: "Reset to global defaults",
}),
);
refreshConfig();
} catch {
toast.error(
t(`${translationKey}.toast.resetError`, {
defaultValue: "Failed to reset settings",
}),
);
}
}, [level, cameraName, requiresRestart, t, refreshConfig, resetToGlobal]);
if (!sectionSchema) {
return null;
}
const title = t(`${translationKey}.title`, {
defaultValue: sectionPath.charAt(0).toUpperCase() + sectionPath.slice(1),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-lg">{title}</CardTitle>
{showOverrideIndicator && level === "camera" && isOverridden && (
<Badge variant="secondary" className="text-xs">
{t("common.overridden", { defaultValue: "Overridden" })}
</Badge>
)}
</div>
{level === "camera" && isOverridden && (
<Button
variant="ghost"
size="sm"
onClick={handleResetToGlobal}
className="gap-2"
>
<LuRotateCcw className="h-4 w-4" />
{t("common.resetToGlobal", { defaultValue: "Reset to Global" })}
</Button>
)}
</CardHeader>
<CardContent>
<ConfigForm
schema={sectionSchema}
formData={formData}
onSubmit={handleSubmit}
fieldOrder={sectionConfig.fieldOrder}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
disabled={disabled}
readonly={readonly}
formContext={{
level,
cameraName,
globalValue,
cameraValue,
}}
/>
</CardContent>
</Card>
);
};
}
@@ -0,0 +1,37 @@
// Detect Section Component
// Reusable for both global and camera-level detect settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the detect section
export const detectSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"fps",
"width",
"height",
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
fieldGroups: {
resolution: ["width", "height"],
tracking: ["min_initialized", "max_disappeared"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: [
"min_initialized",
"max_disappeared",
"annotation_offset",
"stationary",
],
};
export const DetectSection = createConfigSection({
sectionPath: "detect",
translationKey: "configForm.detect",
defaultConfig: detectSectionConfig,
});
export default DetectSection;
@@ -0,0 +1,20 @@
// Live Section Component
// Reusable for both global and camera-level live settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the live section
export const liveSectionConfig: SectionConfig = {
fieldOrder: ["stream_name", "height", "quality"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: ["quality"],
};
export const LiveSection = createConfigSection({
sectionPath: "live",
translationKey: "configForm.live",
defaultConfig: liveSectionConfig,
});
export default LiveSection;
@@ -0,0 +1,42 @@
// Motion Section Component
// Reusable for both global and camera-level motion settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the motion section
export const motionSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"threshold",
"lightning_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mask",
"mqtt_off_delay",
],
fieldGroups: {
sensitivity: ["threshold", "lightning_threshold", "contour_area"],
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: [
"lightning_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",
"mqtt_off_delay",
],
};
export const MotionSection = createConfigSection({
sectionPath: "motion",
translationKey: "configForm.motion",
defaultConfig: motionSectionConfig,
});
export default MotionSection;
@@ -0,0 +1,20 @@
// Notifications Section Component
// Reusable for both global and camera-level notification settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the notifications section
export const notificationsSectionConfig: SectionConfig = {
fieldOrder: ["enabled", "email"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: [],
};
export const NotificationsSection = createConfigSection({
sectionPath: "notifications",
translationKey: "configForm.notifications",
defaultConfig: notificationsSectionConfig,
});
export default NotificationsSection;
@@ -0,0 +1,23 @@
// Objects Section Component
// Reusable for both global and camera-level objects settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the objects section
export const objectsSectionConfig: SectionConfig = {
fieldOrder: ["track", "alert", "detect", "filters", "mask"],
fieldGroups: {
tracking: ["track", "alert", "detect"],
filtering: ["filters", "mask"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["filters", "mask"],
};
export const ObjectsSection = createConfigSection({
sectionPath: "objects",
translationKey: "configForm.objects",
defaultConfig: objectsSectionConfig,
});
export default ObjectsSection;
@@ -0,0 +1,32 @@
// Record Section Component
// Reusable for both global and camera-level record settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the record section
export const recordSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"expire_interval",
"continuous",
"motion",
"alerts",
"detections",
"preview",
"export",
],
fieldGroups: {
retention: ["continuous", "motion"],
events: ["alerts", "detections"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["expire_interval", "preview", "export"],
};
export const RecordSection = createConfigSection({
sectionPath: "record",
translationKey: "configForm.record",
defaultConfig: recordSectionConfig,
});
export default RecordSection;
@@ -0,0 +1,20 @@
// Review Section Component
// Reusable for both global and camera-level review settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the review section
export const reviewSectionConfig: SectionConfig = {
fieldOrder: ["alerts", "detections"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
advancedFields: [],
};
export const ReviewSection = createConfigSection({
sectionPath: "review",
translationKey: "configForm.review",
defaultConfig: reviewSectionConfig,
});
export default ReviewSection;
@@ -0,0 +1,29 @@
// Snapshots Section Component
// Reusable for both global and camera-level snapshots settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the snapshots section
export const snapshotsSectionConfig: SectionConfig = {
fieldOrder: [
"enabled",
"bounding_box",
"crop",
"quality",
"timestamp",
"retain",
],
fieldGroups: {
display: ["bounding_box", "crop", "quality", "timestamp"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["quality", "retain"],
};
export const SnapshotsSection = createConfigSection({
sectionPath: "snapshots",
translationKey: "configForm.snapshots",
defaultConfig: snapshotsSectionConfig,
});
export default SnapshotsSection;
@@ -0,0 +1,22 @@
// Timestamp Section Component
// Reusable for both global and camera-level timestamp_style settings
import { createConfigSection, type SectionConfig } from "./BaseSection";
// Configuration for the timestamp_style section
export const timestampSectionConfig: SectionConfig = {
fieldOrder: ["position", "format", "color", "thickness", "effect"],
fieldGroups: {
appearance: ["color", "thickness", "effect"],
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["thickness", "effect"],
};
export const TimestampSection = createConfigSection({
sectionPath: "timestamp_style",
translationKey: "configForm.timestampStyle",
defaultConfig: timestampSectionConfig,
});
export default TimestampSection;
@@ -0,0 +1,23 @@
// Config Form Section Components
// Reusable components for both global and camera-level settings
export {
createConfigSection,
type BaseSectionProps,
type SectionConfig,
type CreateSectionOptions,
} from "./BaseSection";
export { DetectSection, detectSectionConfig } from "./DetectSection";
export { RecordSection, recordSectionConfig } from "./RecordSection";
export { SnapshotsSection, snapshotsSectionConfig } from "./SnapshotsSection";
export { MotionSection, motionSectionConfig } from "./MotionSection";
export { ObjectsSection, objectsSectionConfig } from "./ObjectsSection";
export { ReviewSection, reviewSectionConfig } from "./ReviewSection";
export { AudioSection, audioSectionConfig } from "./AudioSection";
export {
NotificationsSection,
notificationsSectionConfig,
} from "./NotificationsSection";
export { LiveSection, liveSectionConfig } from "./LiveSection";
export { TimestampSection, timestampSectionConfig } from "./TimestampSection";
@@ -0,0 +1,67 @@
// Utilities for handling anyOf with null patterns
import type { StrictRJSFSchema } from "@rjsf/utils";
/**
* Checks if a schema is anyOf with exactly [PrimitiveType, null]
* where the primitive has no additional constraints
*/
export function isSimpleNullableField(schema: StrictRJSFSchema): boolean {
if (
!schema.anyOf ||
!Array.isArray(schema.anyOf) ||
schema.anyOf.length !== 2
) {
return false;
}
const items = schema.anyOf;
let hasNull = false;
let simpleType: StrictRJSFSchema | null = null;
// eslint-disable-next-line no-restricted-syntax
for (const item of items) {
if (typeof item !== "object" || item === null) {
return false;
}
const itemSchema = item as StrictRJSFSchema;
if (itemSchema.type === "null") {
hasNull = true;
} else if (
itemSchema.type &&
!("$ref" in itemSchema) &&
!("additionalProperties" in itemSchema) &&
!("items" in itemSchema) &&
!("pattern" in itemSchema) &&
!("minimum" in itemSchema) &&
!("maximum" in itemSchema) &&
!("exclusiveMinimum" in itemSchema) &&
!("exclusiveMaximum" in itemSchema)
) {
simpleType = itemSchema;
}
}
return hasNull && simpleType !== null;
}
/**
* Get the non-null schema from an anyOf containing [Type, null]
*/
export function getNonNullSchema(
schema: StrictRJSFSchema,
): StrictRJSFSchema | null {
if (!schema.anyOf || !Array.isArray(schema.anyOf)) {
return null;
}
return (
(schema.anyOf.find(
(item) =>
typeof item === "object" &&
item !== null &&
(item as StrictRJSFSchema).type !== "null",
) as StrictRJSFSchema) || null
);
}
@@ -0,0 +1,76 @@
// Custom RJSF Theme for Frigate
// Maps RJSF templates and widgets to shadcn/ui components
import type {
WidgetProps,
FieldTemplateProps,
RegistryWidgetsType,
RegistryFieldsType,
TemplatesType,
} from "@rjsf/utils";
import { getDefaultRegistry } from "@rjsf/core";
import { SwitchWidget } from "./widgets/SwitchWidget";
import { SelectWidget } from "./widgets/SelectWidget";
import { TextWidget } from "./widgets/TextWidget";
import { PasswordWidget } from "./widgets/PasswordWidget";
import { CheckboxWidget } from "./widgets/CheckboxWidget";
import { RangeWidget } from "./widgets/RangeWidget";
import { TagsWidget } from "./widgets/TagsWidget";
import { ColorWidget } from "./widgets/ColorWidget";
import { TextareaWidget } from "./widgets/TextareaWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
import { ArrayFieldTemplate } from "./templates/ArrayFieldTemplate";
import { BaseInputTemplate } from "./templates/BaseInputTemplate";
import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate";
import { TitleFieldTemplate } from "./templates/TitleFieldTemplate";
import { ErrorListTemplate } from "./templates/ErrorListTemplate";
import { SubmitButton } from "./templates/SubmitButton";
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
export interface FrigateTheme {
widgets: RegistryWidgetsType;
templates: Partial<TemplatesType>;
fields: RegistryFieldsType;
}
const defaultRegistry = getDefaultRegistry();
export const frigateTheme: FrigateTheme = {
widgets: {
...defaultRegistry.widgets,
// Override default widgets with shadcn/ui styled versions
TextWidget: TextWidget,
PasswordWidget: PasswordWidget,
SelectWidget: SelectWidget,
CheckboxWidget: CheckboxWidget,
// Custom widgets
switch: SwitchWidget,
password: PasswordWidget,
select: SelectWidget,
range: RangeWidget,
tags: TagsWidget,
color: ColorWidget,
textarea: TextareaWidget,
},
templates: {
...defaultRegistry.templates,
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
ObjectFieldTemplate: ObjectFieldTemplate,
ArrayFieldTemplate: ArrayFieldTemplate,
BaseInputTemplate: BaseInputTemplate as React.ComponentType<WidgetProps>,
DescriptionFieldTemplate: DescriptionFieldTemplate,
TitleFieldTemplate: TitleFieldTemplate,
ErrorListTemplate: ErrorListTemplate,
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
ButtonTemplates: {
...defaultRegistry.templates.ButtonTemplates,
SubmitButton: SubmitButton,
},
},
fields: {
...defaultRegistry.fields,
},
};
@@ -0,0 +1,5 @@
// RJSF Custom Theme
// Maps RJSF components to existing shadcn/ui components
export { frigateTheme } from "./frigateTheme";
export type { FrigateTheme } from "./frigateTheme";
@@ -0,0 +1,101 @@
// Array Field Template - renders array fields with add/remove controls
import type { ArrayFieldTemplateProps } from "@rjsf/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { LuPlus, LuTrash2, LuGripVertical } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface ArrayItem {
key: string;
index: number;
children: React.ReactNode;
hasRemove: boolean;
onDropIndexClick: (index: number) => () => void;
}
export function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
const { items, canAdd, onAddClick, disabled, readonly, schema } = props;
const { t } = useTranslation(["common"]);
// Simple items (strings, numbers) render inline
const isSimpleType =
schema.items &&
typeof schema.items === "object" &&
"type" in schema.items &&
["string", "number", "integer", "boolean"].includes(
schema.items.type as string,
);
return (
<div className="space-y-3">
{items.length === 0 && !canAdd && (
<p className="text-sm italic text-muted-foreground">
{t("no_items", { ns: "common", defaultValue: "No items" })}
</p>
)}
{(items as unknown as ArrayItem[]).map((element: ArrayItem) => (
<div
key={element.key}
className={cn("flex items-start gap-2", !isSimpleType && "flex-col")}
>
{isSimpleType ? (
<div className="flex flex-1 items-center gap-2">
<LuGripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
<div className="flex-1">{element.children}</div>
{element.hasRemove && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={element.onDropIndexClick(element.index)}
disabled={disabled || readonly}
className="h-8 w-8 text-destructive hover:text-destructive"
>
<LuTrash2 className="h-4 w-4" />
</Button>
)}
</div>
) : (
<Card className="w-full">
<CardContent className="pt-4">
<div className="flex items-start gap-2">
<LuGripVertical className="mt-2 h-4 w-4 cursor-move text-muted-foreground" />
<div className="flex-1">{element.children}</div>
{element.hasRemove && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={element.onDropIndexClick(element.index)}
disabled={disabled || readonly}
className="h-8 w-8 text-destructive hover:text-destructive"
>
<LuTrash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
))}
{canAdd && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onAddClick}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("add", { ns: "common", defaultValue: "Add" })}
</Button>
)}
</div>
);
}
@@ -0,0 +1,44 @@
// Base Input Template - default input wrapper
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
export function BaseInputTemplate(props: WidgetProps) {
const {
id,
type,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
} = props;
const inputType = type || "text";
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (inputType === "number") {
const num = parseFloat(val);
onChange(val === "" ? undefined : isNaN(num) ? undefined : num);
} else {
onChange(val === "" ? undefined : val);
}
};
return (
<Input
id={id}
type={inputType}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || ""}
onChange={handleChange}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}
@@ -0,0 +1,16 @@
// Description Field Template
import type { DescriptionFieldProps } from "@rjsf/utils";
export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
const { description, id } = props;
if (!description) {
return null;
}
return (
<p id={id} className="text-sm text-muted-foreground">
{description}
</p>
);
}
@@ -0,0 +1,33 @@
// Error List Template - displays form-level errors
import type { ErrorListProps, RJSFValidationError } from "@rjsf/utils";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { LuCircleAlert } from "react-icons/lu";
import { useTranslation } from "react-i18next";
export function ErrorListTemplate(props: ErrorListProps) {
const { errors } = props;
const { t } = useTranslation(["common"]);
if (!errors || errors.length === 0) {
return null;
}
return (
<Alert variant="destructive" className="mb-4">
<LuCircleAlert className="h-4 w-4" />
<AlertTitle>{t("validation_errors", { ns: "common" })}</AlertTitle>
<AlertDescription>
<ul className="mt-2 list-inside list-disc space-y-1">
{errors.map((error: RJSFValidationError, index: number) => (
<li key={index} className="text-sm">
{error.property && (
<span className="font-medium">{error.property}: </span>
)}
{error.message}
</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,81 @@
// Field Template - wraps each form field with label and description
import type { FieldTemplateProps } from "@rjsf/utils";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
export function FieldTemplate(props: FieldTemplateProps) {
const {
id,
label,
children,
errors,
help,
description,
hidden,
required,
displayLabel,
schema,
uiSchema,
} = props;
if (hidden) {
return <div className="hidden">{children}</div>;
}
// Get UI options
const uiOptions = uiSchema?.["ui:options"] || {};
const isAdvanced = uiOptions.advanced === true;
// Boolean fields (switches) render label inline
const isBoolean = schema.type === "boolean";
return (
<div
className={cn(
"space-y-2",
isAdvanced && "border-l-2 border-muted pl-4",
isBoolean && "flex items-center justify-between gap-4",
)}
>
{displayLabel && label && !isBoolean && (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
errors && errors.props?.errors?.length > 0 && "text-destructive",
)}
>
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{isBoolean ? (
<div className="flex w-full items-center justify-between gap-4">
<div className="space-y-0.5">
{displayLabel && label && (
<Label htmlFor={id} className="text-sm font-medium">
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{children}
</div>
) : (
<>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{children}
</>
)}
{errors}
{help}
</div>
);
}
@@ -0,0 +1,40 @@
// Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields
// Renders simple nullable types as single inputs instead of dropdowns
import type {
MultiSchemaFieldTemplateProps,
StrictRJSFSchema,
FormContextType,
} from "@rjsf/utils";
import { isSimpleNullableField } from "../fields/nullableUtils";
/**
* Custom MultiSchemaFieldTemplate that:
* 1. Renders simple anyOf [Type, null] fields as single inputs
* 2. Falls back to default behavior for complex types
*/
export function MultiSchemaFieldTemplate<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T = any,
S extends StrictRJSFSchema = StrictRJSFSchema,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
F extends FormContextType = any,
>(props: MultiSchemaFieldTemplateProps<T, S, F>): JSX.Element {
const { schema, selector, optionSchemaField } = props;
// Check if this is a simple nullable field that should be handled specially
if (isSimpleNullableField(schema)) {
// For simple nullable fields, just render the field directly without the dropdown selector
// This handles the case where empty input = null
return <>{optionSchemaField}</>;
}
// For all other cases, render with both selector and field (default MultiSchemaField behavior)
return (
<>
{selector}
{optionSchemaField}
</>
);
}
@@ -0,0 +1,118 @@
// Object Field Template - renders nested object fields
import type { ObjectFieldTemplateProps } from "@rjsf/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const { title, description, properties } = props;
// Check if this is a root-level object
const isRoot = !title;
const [isOpen, setIsOpen] = useState(true);
// Check for advanced section grouping
const advancedProps = properties.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
);
const regularProps = properties.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
);
const [showAdvanced, setShowAdvanced] = useState(false);
// Root level renders children directly
if (isRoot) {
return (
<div className="space-y-6">
{regularProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2">
{showAdvanced ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
Advanced Settings ({advancedProps.length})
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{advancedProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}
// Nested objects render as collapsible cards
return (
<Card>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{title}</CardTitle>
{description && (
<p className="mt-1 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
{isOpen ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{regularProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
>
{showAdvanced ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
Advanced ({advancedProps.length})
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{advancedProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
@@ -0,0 +1,21 @@
// Submit Button Template
import type { SubmitButtonProps } from "@rjsf/utils";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { LuSave } from "react-icons/lu";
export function SubmitButton(props: SubmitButtonProps) {
const { uiSchema } = props;
const { t } = useTranslation(["common"]);
const submitText =
(uiSchema?.["ui:options"]?.submitText as string) ||
t("save", { ns: "common" });
return (
<Button type="submit" className="gap-2">
<LuSave className="h-4 w-4" />
{submitText}
</Button>
);
}
@@ -0,0 +1,17 @@
// Title Field Template
import type { TitleFieldProps } from "@rjsf/utils";
export function TitleFieldTemplate(props: TitleFieldProps) {
const { title, id, required } = props;
if (!title) {
return null;
}
return (
<h3 id={id} className="text-lg font-semibold">
{title}
{required && <span className="ml-1 text-destructive">*</span>}
</h3>
);
}
@@ -0,0 +1,17 @@
// Checkbox Widget - maps to shadcn/ui Checkbox
import type { WidgetProps } from "@rjsf/utils";
import { Checkbox } from "@/components/ui/checkbox";
export function CheckboxWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, label, schema } = props;
return (
<Checkbox
id={id}
checked={typeof value === "undefined" ? false : value}
disabled={disabled || readonly}
onCheckedChange={(checked) => onChange(checked)}
aria-label={label || schema.title || "Checkbox"}
/>
);
}
@@ -0,0 +1,53 @@
// Color Widget - For RGB color objects
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useMemo, useCallback } from "react";
interface RGBColor {
red: number;
green: number;
blue: number;
}
export function ColorWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange } = props;
// Convert object to hex for color picker
const hexValue = useMemo(() => {
if (!value || typeof value !== "object") {
return "#ffffff";
}
const { red = 255, green = 255, blue = 255 } = value as RGBColor;
return `#${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`;
}, [value]);
const handleColorChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const hex = e.target.value;
const red = parseInt(hex.slice(1, 3), 16);
const green = parseInt(hex.slice(3, 5), 16);
const blue = parseInt(hex.slice(5, 7), 16);
onChange({ red, green, blue });
},
[onChange],
);
return (
<div className="flex items-center gap-3">
<Input
id={id}
type="color"
value={hexValue}
disabled={disabled || readonly}
onChange={handleColorChange}
className="h-10 w-14 cursor-pointer p-1"
/>
<div className="flex gap-2 text-sm text-muted-foreground">
<Label>R: {(value as RGBColor)?.red ?? 255}</Label>
<Label>G: {(value as RGBColor)?.green ?? 255}</Label>
<Label>B: {(value as RGBColor)?.blue ?? 255}</Label>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
// Number Widget - Input with number type
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
export function NumberWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
schema,
options,
} = props;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (val === "") {
onChange(undefined);
} else {
const num =
schema.type === "integer" ? parseInt(val, 10) : parseFloat(val);
onChange(isNaN(num) ? undefined : num);
}
};
return (
<Input
id={id}
type="number"
value={value ?? ""}
disabled={disabled || readonly}
min={schema.minimum}
max={schema.maximum}
step={(options.step as number) || (schema.type === "integer" ? 1 : 0.1)}
onChange={handleChange}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}
@@ -0,0 +1,55 @@
// Password Widget - Input with password type
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
export function PasswordWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
} = props;
const [showPassword, setShowPassword] = useState(false);
return (
<div className="relative">
<Input
id={id}
type={showPassword ? "text" : "password"}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
>
{showPassword ? (
<LuEyeOff className="h-4 w-4" />
) : (
<LuEye className="h-4 w-4" />
)}
</Button>
</div>
);
}
@@ -0,0 +1,31 @@
// Range Widget - maps to shadcn/ui Slider
import type { WidgetProps } from "@rjsf/utils";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
export function RangeWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options } = props;
const min = schema.minimum ?? 0;
const max = schema.maximum ?? 100;
const step =
(options.step as number) || (schema.type === "integer" ? 1 : 0.1);
return (
<div className="flex items-center gap-4">
<Slider
id={id}
value={[value ?? min]}
min={min}
max={max}
step={step}
disabled={disabled || readonly}
onValueChange={(vals) => onChange(vals[0])}
className={cn("flex-1", disabled && "opacity-50")}
/>
<span className="w-12 text-right text-sm text-muted-foreground">
{value ?? min}
</span>
</div>
);
}
@@ -0,0 +1,49 @@
// Select Widget - maps to shadcn/ui Select
import type { WidgetProps } from "@rjsf/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function SelectWidget(props: WidgetProps) {
const {
id,
options,
value,
disabled,
readonly,
onChange,
placeholder,
schema,
} = props;
const { enumOptions = [] } = options;
return (
<Select
value={value?.toString() ?? ""}
onValueChange={(val) => {
// Convert back to original type if needed
const enumVal = enumOptions.find(
(opt: { value: unknown }) => opt.value?.toString() === val,
);
onChange(enumVal ? enumVal.value : val);
}}
disabled={disabled || readonly}
>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder={placeholder || schema.title || "Select..."} />
</SelectTrigger>
<SelectContent>
{enumOptions.map((option: { value: unknown; label: string }) => (
<SelectItem key={String(option.value)} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,17 @@
// Switch Widget - maps to shadcn/ui Switch
import type { WidgetProps } from "@rjsf/utils";
import { Switch } from "@/components/ui/switch";
export function SwitchWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, label, schema } = props;
return (
<Switch
id={id}
checked={typeof value === "undefined" ? false : value}
disabled={disabled || readonly}
onCheckedChange={(checked) => onChange(checked)}
aria-label={label || schema.title || "Toggle"}
/>
);
}
@@ -0,0 +1,74 @@
// Tags Widget - For array of strings input
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useState, useCallback, useMemo } from "react";
import { LuX } from "react-icons/lu";
export function TagsWidget(props: WidgetProps) {
const { id, value = [], disabled, readonly, onChange, schema } = props;
const [inputValue, setInputValue] = useState("");
const tags = useMemo(() => (Array.isArray(value) ? value : []), [value]);
const addTag = useCallback(() => {
const trimmed = inputValue.trim();
if (trimmed && !tags.includes(trimmed)) {
onChange([...tags, trimmed]);
setInputValue("");
}
}, [inputValue, tags, onChange]);
const removeTag = useCallback(
(tagToRemove: string) => {
onChange(tags.filter((tag: string) => tag !== tagToRemove));
},
[tags, onChange],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
} else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) {
removeTag(tags[tags.length - 1]);
}
};
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1">
{tags.map((tag: string, index: number) => (
<Badge key={`${tag}-${index}`} variant="secondary" className="gap-1">
{tag}
{!disabled && !readonly && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0 hover:bg-transparent"
onClick={() => removeTag(tag)}
>
<LuX className="h-3 w-3" />
</Button>
)}
</Badge>
))}
</div>
{!readonly && (
<Input
id={id}
type="text"
value={inputValue}
disabled={disabled}
placeholder={`Add ${schema.title?.toLowerCase() || "item"}...`}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={addTag}
/>
)}
</div>
);
}
@@ -0,0 +1,34 @@
// Text Widget - maps to shadcn/ui Input
import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
export function TextWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
return (
<Input
id={id}
type="text"
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}
@@ -0,0 +1,34 @@
// Textarea Widget - maps to shadcn/ui Textarea
import type { WidgetProps } from "@rjsf/utils";
import { Textarea } from "@/components/ui/textarea";
export function TextareaWidget(props: WidgetProps) {
const {
id,
value,
disabled,
readonly,
onChange,
onBlur,
onFocus,
placeholder,
schema,
options,
} = props;
return (
<Textarea
id={id}
value={value ?? ""}
disabled={disabled || readonly}
placeholder={placeholder || (options.placeholder as string) || ""}
rows={(options.rows as number) || 3}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
onBlur={(e) => onBlur(id, e.target.value)}
onFocus={(e) => onFocus(id, e.target.value)}
aria-label={schema.title}
/>
);
}