diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index d7e6a7caa..17ffa8aac 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -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": { diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 23b85ad6e..78791e4c1 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -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, }} /> diff --git a/web/src/components/config-form/sections/MotionSection.tsx b/web/src/components/config-form/sections/MotionSection.tsx index 87b1a4682..f4ee9a21c 100644 --- a/web/src/components/config-form/sections/MotionSection.tsx +++ b/web/src/components/config-form/sections/MotionSection.tsx @@ -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", diff --git a/web/src/components/config-form/sections/ObjectsSection.tsx b/web/src/components/config-form/sections/ObjectsSection.tsx index fd3ab1ba4..870660f17 100644 --- a/web/src/components/config-form/sections/ObjectsSection.tsx +++ b/web/src/components/config-form/sections/ObjectsSection.tsx @@ -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"], }, }); diff --git a/web/src/components/config-form/sections/ReviewSection.tsx b/web/src/components/config-form/sections/ReviewSection.tsx index 5dbee249a..a8ef4734f 100644 --- a/web/src/components/config-form/sections/ReviewSection.tsx +++ b/web/src/components/config-form/sections/ReviewSection.tsx @@ -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: [], }, }); diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 4c847261a..cd5d8cde0 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -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, diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index 0d988f1fb..b55e7aa19 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -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 | 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 && ( )} - {finalDescription && !isNullableUnion && ( + {finalDescription && !isMultiSchemaWrapper && (

- {String(finalDescription)} + {finalDescription}

)} @@ -136,7 +144,7 @@ export function FieldTemplate(props: FieldTemplateProps) { ) : ( <> - {finalDescription && !isNullableUnion && ( + {finalDescription && !isMultiSchemaWrapper && (

{finalDescription}

)} {children} diff --git a/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx b/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx index a21fe5068..e020beb99 100644 --- a/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/MultiSchemaFieldTemplate.tsx @@ -20,10 +20,15 @@ export function MultiSchemaFieldTemplate< // eslint-disable-next-line @typescript-eslint/no-explicit-any F extends FormContextType = any, >(props: MultiSchemaFieldTemplateProps): JSX.Element { - const { schema, selector, optionSchemaField } = props; + const { schema, selector, optionSchemaField, uiSchema } = props; + + const uiOptions = uiSchema?.["ui:options"] as + | Record + | 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}; diff --git a/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx new file mode 100644 index 000000000..db4735011 --- /dev/null +++ b/web/src/components/config-form/theme/widgets/ObjectLabelSwitchesWidget.tsx @@ -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 ( + + ); +} diff --git a/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx new file mode 100644 index 000000000..26bc54fae --- /dev/null +++ b/web/src/components/config-form/theme/widgets/SwitchesWidget.tsx @@ -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; + globalValue?: Record; + fullCameraConfig?: Record; + t?: (key: string, options?: Record) => 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 ( + +
+ + + + + + {availableEntities.length === 0 ? ( +
{emptyMessage}
+ ) : ( +
+ {availableEntities.map((entity) => { + const checked = selectedEntities.includes(entity); + const displayLabel = getDisplayLabel(entity, context); + return ( +
+ + toggleEntity(entity, !!value)} + /> +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/web/src/components/config-form/theme/widgets/ZoneSwitchesWidget.tsx b/web/src/components/config-form/theme/widgets/ZoneSwitchesWidget.tsx new file mode 100644 index 000000000..c3bf4b09c --- /dev/null +++ b/web/src/components/config-form/theme/widgets/ZoneSwitchesWidget.tsx @@ -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)[ + 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 ( + + ); +} diff --git a/web/src/views/settings/CameraConfigView.tsx b/web/src/views/settings/CameraConfigView.tsx index c52633bc5..eaab5201b 100644 --- a/web/src/views/settings/CameraConfigView.tsx +++ b/web/src/views/settings/CameraConfigView.tsx @@ -264,7 +264,7 @@ const CameraConfigContent = memo(function CameraConfigContent({