From 8de6f2b4cfca5389dc500f0afd3b44648f9163ba Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:06:18 -0500 Subject: [PATCH] improve known_plates field in settings UI --- web/public/locales/en/views/settings.json | 4 + .../config-form/section-configs/lpr.ts | 7 + .../theme/fields/KnownPlatesField.tsx | 277 ++++++++++++++++++ .../config-form/theme/frigateTheme.ts | 2 + 4 files changed, 290 insertions(+) create mode 100644 web/src/components/config-form/theme/fields/KnownPlatesField.tsx diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a151e9ca9..a1e14452e 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1326,6 +1326,10 @@ "keyPlaceholder": "New key", "remove": "Remove" }, + "knownPlates": { + "namePlaceholder": "e.g., Wife's Car", + "platePlaceholder": "Plate number or regex" + }, "timezone": { "defaultOption": "Use browser timezone" }, diff --git a/web/src/components/config-form/section-configs/lpr.ts b/web/src/components/config-form/section-configs/lpr.ts index 4997d766f..0567c6cf4 100644 --- a/web/src/components/config-form/section-configs/lpr.ts +++ b/web/src/components/config-form/section-configs/lpr.ts @@ -67,6 +67,13 @@ const lpr: SectionConfigOverrides = { format: { "ui:options": { size: "md" }, }, + known_plates: { + "ui:field": "KnownPlatesField", + "ui:options": { + label: false, + suppressDescription: true, + }, + }, replace_rules: { "ui:field": "ReplaceRulesField", "ui:options": { diff --git a/web/src/components/config-form/theme/fields/KnownPlatesField.tsx b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx new file mode 100644 index 000000000..f710dcd11 --- /dev/null +++ b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx @@ -0,0 +1,277 @@ +import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { + LuChevronDown, + LuChevronRight, + LuPlus, + LuTrash2, +} from "react-icons/lu"; +import type { ConfigFormContext } from "@/types/configForm"; +import get from "lodash/get"; +import { isSubtreeModified } from "../utils"; + +type KnownPlatesData = Record; + +export function KnownPlatesField(props: FieldProps) { + const { schema, formData, onChange, idSchema, disabled, readonly } = props; + const formContext = props.registry?.formContext as + | ConfigFormContext + | undefined; + + const { t } = useTranslation(["views/settings", "common"]); + + const data: KnownPlatesData = useMemo(() => { + if (!formData || typeof formData !== "object" || Array.isArray(formData)) { + return {}; + } + return formData as KnownPlatesData; + }, [formData]); + + const entries = useMemo(() => Object.entries(data), [data]); + + const title = (schema as RJSFSchema).title; + const description = (schema as RJSFSchema).description; + + const hasItems = entries.length > 0; + const emptyPath = useMemo(() => [] as FieldPathList, []); + const fieldPath = + (props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ?? + emptyPath; + + const isModified = useMemo(() => { + const baselineRoot = formContext?.baselineFormData; + const baselineValue = baselineRoot + ? get(baselineRoot, fieldPath) + : undefined; + return isSubtreeModified( + data, + baselineValue, + formContext?.overrides, + fieldPath, + formContext?.formData, + ); + }, [fieldPath, formContext, data]); + + const [open, setOpen] = useState(hasItems || isModified); + + useEffect(() => { + if (isModified) { + setOpen(true); + } + }, [isModified]); + + useEffect(() => { + if (hasItems) { + setOpen(true); + } + }, [hasItems]); + + const handleAddEntry = useCallback(() => { + const next = { ...data, "": [""] }; + onChange(next, fieldPath); + }, [data, fieldPath, onChange]); + + const handleRemoveEntry = useCallback( + (key: string) => { + const next = { ...data }; + delete next[key]; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleRenameKey = useCallback( + (oldKey: string, newKey: string) => { + if (oldKey === newKey) return; + // Preserve order by rebuilding the object + const next: KnownPlatesData = {}; + for (const [k, v] of Object.entries(data)) { + if (k === oldKey) { + next[newKey] = v; + } else { + next[k] = v; + } + } + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleAddPlate = useCallback( + (key: string) => { + const next = { ...data, [key]: [...(data[key] || []), ""] }; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleRemovePlate = useCallback( + (key: string, plateIndex: number) => { + const plates = [...(data[key] || [])]; + plates.splice(plateIndex, 1); + const next = { ...data, [key]: plates }; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleUpdatePlate = useCallback( + (key: string, plateIndex: number, value: string) => { + const plates = [...(data[key] || [])]; + plates[plateIndex] = value; + const next = { ...data, [key]: plates }; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const baseId = idSchema?.$id || "known_plates"; + const deleteLabel = t("button.delete", { + ns: "common", + defaultValue: "Delete", + }); + const namePlaceholder = t("configForm.knownPlates.namePlaceholder", { + ns: "views/settings", + }); + const platePlaceholder = t("configForm.knownPlates.platePlaceholder", { + ns: "views/settings", + }); + return ( + + + + +
+
+ + {title} + + {description && ( +

+ {description} +

+ )} +
+ {open ? ( + + ) : ( + + )} +
+
+
+ + + + {entries.map(([key, plates], entryIndex) => { + const entryId = `${baseId}-${entryIndex}`; + + return ( +
+
+ handleRenameKey(key, e.target.value)} + className="flex-1" + /> + +
+ +
+ {plates.map((plate, plateIndex) => ( +
+ + handleUpdatePlate(key, plateIndex, e.target.value) + } + className="flex-1" + /> + {plates.length > 1 && ( + + )} +
+ ))} + +
+
+ ); + })} + +
+ +
+
+
+
+
+ ); +} + +export default KnownPlatesField; diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index b7d619fe0..ae612d9ac 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -49,6 +49,7 @@ import { DetectorHardwareField } from "./fields/DetectorHardwareField"; import { ReplaceRulesField } from "./fields/ReplaceRulesField"; import { CameraInputsField } from "./fields/CameraInputsField"; import { DictAsYamlField } from "./fields/DictAsYamlField"; +import { KnownPlatesField } from "./fields/KnownPlatesField"; export interface FrigateTheme { widgets: RegistryWidgetsType; @@ -105,5 +106,6 @@ export const frigateTheme: FrigateTheme = { ReplaceRulesField: ReplaceRulesField, CameraInputsField: CameraInputsField, DictAsYamlField: DictAsYamlField, + KnownPlatesField: KnownPlatesField, }, };