add switches widgets and use friendly names

This commit is contained in:
Josh Hawkins 2026-01-24 11:06:08 -06:00
parent f25b69e3eb
commit 72ab1f93b5
13 changed files with 354 additions and 19 deletions

View File

@ -1201,6 +1201,14 @@
"resetError": "Failed to reset object settings"
}
},
"objectLabels": {
"summary": "Selected {{count}}",
"empty": "No object labels available"
},
"zoneNames": {
"summary": "Selected {{count}}",
"empty": "No zones available"
},
"review": {
"title": "Review Settings",
"toast": {

View File

@ -7,6 +7,7 @@ import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { ConfigForm } from "../ConfigForm";
import type { UiSchema } from "@rjsf/utils";
import {
useConfigOverride,
normalizeConfigValue,
@ -42,6 +43,8 @@ export interface SectionConfig {
hiddenFields?: string[];
/** Fields to show in advanced section */
advancedFields?: string[];
/** Additional uiSchema overrides */
uiSchema?: UiSchema;
}
export interface BaseSectionProps {
@ -315,6 +318,7 @@ export function createConfigSection({
fieldOrder={sectionConfig.fieldOrder}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
uiSchema={sectionConfig.uiSchema}
disabled={disabled || isSaving}
readonly={readonly}
showSubmit={false}
@ -324,6 +328,12 @@ export function createConfigSection({
cameraName,
globalValue,
cameraValue,
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
level === "camera" && cameraName
? config?.cameras?.[cameraName]
: undefined,
t,
}}
/>

View File

@ -20,14 +20,12 @@ export const MotionSection = createConfigSection({
"mqtt_off_delay",
],
fieldGroups: {
sensitivity: ["threshold", "lightning_threshold", "contour_area"],
sensitivity: ["threshold", "contour_area"],
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
},
hiddenFields: ["enabled_in_config"],
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
advancedFields: [
"lightning_threshold",
"improve_contrast",
"contour_area",
"delta_alpha",
"frame_alpha",
"frame_height",

View File

@ -7,13 +7,35 @@ export const ObjectsSection = createConfigSection({
sectionPath: "objects",
i18nNamespace: "config/objects",
defaultConfig: {
fieldOrder: ["track", "alert", "detect", "filters", "mask"],
fieldOrder: ["track", "alert", "detect", "filters"],
fieldGroups: {
tracking: ["track", "alert", "detect"],
filtering: ["filters", "mask"],
filtering: ["filters"],
},
hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
advancedFields: ["filters"],
uiSchema: {
track: {
"ui:widget": "objectLabels",
"ui:options": {
suppressMultiSchema: true,
},
},
genai: {
objects: {
"ui:widget": "objectLabels",
"ui:options": {
suppressMultiSchema: true,
},
},
required_zones: {
"ui:widget": "zoneNames",
"ui:options": {
suppressMultiSchema: true,
},
},
},
},
hiddenFields: ["enabled_in_config"],
advancedFields: ["filters", "mask"],
},
});

View File

@ -9,7 +9,13 @@ export const ReviewSection = createConfigSection({
defaultConfig: {
fieldOrder: ["alerts", "detections"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],
hiddenFields: [
"enabled_in_config",
"alerts.labels",
"alerts.required_zones",
"detections.labels",
"detections.required_zones",
],
advancedFields: [],
},
});

View File

@ -14,11 +14,13 @@ 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 { SwitchesWidget } from "./widgets/SwitchesWidget";
import { ObjectLabelSwitchesWidget } from "./widgets/ObjectLabelSwitchesWidget";
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
@ -45,7 +47,7 @@ export const frigateTheme: FrigateTheme = {
TextWidget: TextWidget,
PasswordWidget: PasswordWidget,
SelectWidget: SelectWidget,
CheckboxWidget: CheckboxWidget,
CheckboxWidget: SwitchWidget,
// Custom widgets
switch: SwitchWidget,
password: PasswordWidget,
@ -54,6 +56,9 @@ export const frigateTheme: FrigateTheme = {
tags: TagsWidget,
color: ColorWidget,
textarea: TextareaWidget,
switches: SwitchesWidget,
objectLabels: ObjectLabelSwitchesWidget,
zoneNames: ZoneSwitchesWidget,
},
templates: {
...defaultRegistry.templates,

View File

@ -49,6 +49,14 @@ export function FieldTemplate(props: FieldTemplateProps) {
const isBoolean = schema.type === "boolean";
const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema);
const suppressMultiSchema =
(uiSchema?.["ui:options"] as Record<string, unknown> | undefined)
?.suppressMultiSchema === true;
// Only suppress labels/descriptions if this is a multi-schema field (anyOf/oneOf) with suppressMultiSchema flag
// This prevents duplicate labels while still showing the inner field's label
const isMultiSchemaWrapper =
(schema.anyOf || schema.oneOf) && (suppressMultiSchema || isNullableUnion);
// Get translation path for this field
const translationPath = buildTranslationPath(fieldPathId.path);
@ -104,7 +112,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
)}
data-field-id={translationPath}
>
{displayLabel && finalLabel && !isBoolean && !isNullableUnion && (
{displayLabel && finalLabel && !isBoolean && !isMultiSchemaWrapper && (
<Label
htmlFor={id}
className={cn(
@ -126,9 +134,9 @@ export function FieldTemplate(props: FieldTemplateProps) {
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{finalDescription && !isNullableUnion && (
{finalDescription && !isMultiSchemaWrapper && (
<p className="max-w-md text-sm text-muted-foreground">
{String(finalDescription)}
{finalDescription}
</p>
)}
</div>
@ -136,7 +144,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
</div>
) : (
<>
{finalDescription && !isNullableUnion && (
{finalDescription && !isMultiSchemaWrapper && (
<p className="text-sm text-muted-foreground">{finalDescription}</p>
)}
{children}

View File

@ -20,10 +20,15 @@ export function MultiSchemaFieldTemplate<
// 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;
const { schema, selector, optionSchemaField, uiSchema } = props;
const uiOptions = uiSchema?.["ui:options"] as
| Record<string, unknown>
| undefined;
const suppressMultiSchema = uiOptions?.suppressMultiSchema === true;
// Check if this is a simple nullable field that should be handled specially
if (isNullableUnionSchema(schema)) {
if (isNullableUnionSchema(schema) || suppressMultiSchema) {
// For simple nullable fields, just render the field directly without the dropdown selector
// This handles the case where empty input = null
return <>{optionSchemaField}</>;

View File

@ -0,0 +1,48 @@
// Object Label Switches Widget - For selecting objects via switches
import type { WidgetProps } from "@rjsf/utils";
import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n";
function getObjectLabels(context: FormContext): string[] {
let cameraLabels: string[] = [];
let globalLabels: string[] = [];
if (context) {
// context.cameraValue and context.globalValue should be the entire objects section
const trackValue = context.cameraValue?.track;
if (Array.isArray(trackValue)) {
cameraLabels = trackValue.filter(
(item): item is string => typeof item === "string",
);
}
const globalTrackValue = context.globalValue?.track;
if (Array.isArray(globalTrackValue)) {
globalLabels = globalTrackValue.filter(
(item): item is string => typeof item === "string",
);
}
}
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
return [...sourceLabels].sort();
}
function getObjectLabelDisplayName(label: string): string {
return getTranslatedLabel(label, "object");
}
export function ObjectLabelSwitchesWidget(props: WidgetProps) {
return (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities: getObjectLabels,
getDisplayLabel: getObjectLabelDisplayName,
i18nKey: "objectLabels",
}}
/>
);
}

View File

@ -0,0 +1,177 @@
// Generic Switches Widget - Reusable component for selecting from any list of entities
import type { WidgetProps } from "@rjsf/utils";
import { useMemo, useState } from "react";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
type FormContext = {
cameraValue?: Record<string, unknown>;
globalValue?: Record<string, unknown>;
fullCameraConfig?: Record<string, unknown>;
t?: (key: string, options?: Record<string, unknown>) => string;
};
export type { FormContext };
export type SwitchesWidgetOptions = {
/** Function to extract available entities from context */
getEntities: (context: FormContext) => string[];
/** Function to get display label for an entity (e.g., translate, get friendly name) */
getDisplayLabel?: (entity: string, context?: FormContext) => string;
/** i18n key prefix (e.g., "objectLabels", "zoneNames") */
i18nKey: string;
/** Translation namespace (default: "views/settings") */
namespace?: string;
};
function normalizeValue(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
if (typeof value === "string" && value.trim().length > 0) {
return [value.trim()];
}
return [];
}
/**
* Generic switches widget for selecting from any list of entities (objects, zones, etc.)
*
* @example
* // In uiSchema:
* "track": {
* "ui:widget": "switches",
* "ui:options": {
* "getEntities": (context) => [...],
* "i18nKey": "objectLabels"
* }
* }
*/
export function SwitchesWidget(props: WidgetProps) {
const { value, disabled, readonly, onChange, formContext, id, registry } =
props;
// Get configuration from widget options
const i18nKey = useMemo(
() => (props.options?.i18nKey as string | undefined) || "entities",
[props.options],
);
const namespace = useMemo(
() => (props.options?.namespace as string | undefined) || "views/settings",
[props.options],
);
// Try to get formContext from direct prop, options, or registry
const context = useMemo(
() =>
(formContext as FormContext | undefined) ||
(props.options?.formContext as FormContext | undefined) ||
(registry?.formContext as FormContext | undefined),
[formContext, props.options, registry],
);
const availableEntities = useMemo(() => {
const getEntities =
(props.options?.getEntities as
| ((context: FormContext) => string[])
| undefined) || (() => []);
if (context) {
return getEntities(context);
}
return [];
}, [context, props.options]);
const getDisplayLabel = useMemo(
() =>
(props.options?.getDisplayLabel as
| ((entity: string, context?: FormContext) => string)
| undefined) || ((entity: string) => entity),
[props.options],
);
const selectedEntities = useMemo(() => normalizeValue(value), [value]);
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
const toggleEntity = (entity: string, enabled: boolean) => {
if (enabled) {
onChange([...selectedEntities, entity]);
} else {
onChange(selectedEntities.filter((item) => item !== entity));
}
};
const t = context?.t;
const summary = t
? t(`configForm.${i18nKey}.summary`, {
ns: namespace,
defaultValue: "Selected {{count}}",
count: selectedEntities.length,
})
: `Selected ${selectedEntities.length}`;
const emptyMessage = t
? t(`configForm.${i18nKey}.empty`, {
ns: namespace,
defaultValue: "No items available",
})
: "No items available";
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="space-y-3">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="w-full justify-start gap-2"
disabled={disabled || readonly}
>
{isOpen ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
{summary}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{availableEntities.length === 0 ? (
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
) : (
<div className="grid gap-2">
{availableEntities.map((entity) => {
const checked = selectedEntities.includes(entity);
const displayLabel = getDisplayLabel(entity, context);
return (
<div
key={entity}
className="flex items-center justify-between rounded-md px-3 py-0"
>
<label htmlFor={`${id}-${entity}`} className="text-sm">
{displayLabel}
</label>
<Switch
id={`${id}-${entity}`}
checked={checked}
disabled={disabled || readonly}
onCheckedChange={(value) => toggleEntity(entity, !!value)}
/>
</div>
);
})}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}

View File

@ -0,0 +1,48 @@
// Zone Switches Widget - For selecting zones via switches
import type { WidgetProps } from "@rjsf/utils";
import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget";
function getZoneNames(context: FormContext): string[] {
if (context?.fullCameraConfig) {
const zones = context.fullCameraConfig.zones;
if (typeof zones === "object" && zones !== null) {
// zones is a dict/object, get the keys
return Object.keys(zones).sort();
}
}
return [];
}
function getZoneDisplayName(zoneName: string, context?: FormContext): string {
// Try to get the config from context
// In the config form context, we may not have the full config directly,
// so we'll try to use the zone config if available
if (context?.fullCameraConfig?.zones) {
const zones = context.fullCameraConfig.zones;
if (typeof zones === "object" && zones !== null) {
const zoneConfig = (zones as Record<string, { friendly_name?: string }>)[
zoneName
];
if (zoneConfig?.friendly_name) {
return zoneConfig.friendly_name;
}
}
}
// Fallback to cleaning up the zone name
return String(zoneName).replace(/_/g, " ");
}
export function ZoneSwitchesWidget(props: WidgetProps) {
return (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities: getZoneNames,
getDisplayLabel: getZoneDisplayName,
i18nKey: "zoneNames",
}}
/>
);
}

View File

@ -264,7 +264,7 @@ const CameraConfigContent = memo(function CameraConfigContent({
<button
onClick={() => setActiveSection(section.key)}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
activeSection === section.key
? "bg-accent text-accent-foreground"
: "hover:bg-muted",

View File

@ -414,7 +414,7 @@ export default function GlobalConfigView() {
<button
onClick={() => setActiveSection(section.key)}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
activeSection === section.key
? "bg-accent text-accent-foreground"
: "hover:bg-muted",