mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
add switches widgets and use friendly names
This commit is contained in:
parent
f25b69e3eb
commit
72ab1f93b5
@ -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": {
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}</>;
|
||||
|
||||
@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
177
web/src/components/config-form/theme/widgets/SwitchesWidget.tsx
Normal file
177
web/src/components/config-form/theme/widgets/SwitchesWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user