import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Command, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { LuCheck, LuChevronDown, LuChevronRight, LuChevronsUpDown, LuPlus, LuTrash2, } from "react-icons/lu"; import type { ConfigFormContext } from "@/types/configForm"; import get from "lodash/get"; import { isSubtreeModified } from "../utils"; import { MapKeyInput } from "../components"; type KnownPlatesData = Record; type PlateComboboxProps = { id: string; value: string; entryName: string; disabled?: boolean; detectedPlates: string[]; plateAssignments: Map; autoOpen: boolean; onAutoOpened: () => void; onCommit: (next: string) => void; }; /** * Plate entry that doubles as a picker for plates Frigate has already * recognized. Free text is still accepted so regexes remain typeable. */ function PlateCombobox({ id, value, entryName, disabled, detectedPlates, plateAssignments, autoOpen, onAutoOpened, onCommit, }: PlateComboboxProps) { const { t } = useTranslation(["views/settings"]); const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(""); const inputRef = useRef(null); useEffect(() => { if (!autoOpen) return; setOpen(true); onAutoOpened(); }, [autoOpen, onAutoOpened]); // Seed the search box with the current plate and select it, so the first // keystroke replaces the plate instead of appending to it. useEffect(() => { if (!open) { setSearchValue(""); return; } setSearchValue(value); const frame = requestAnimationFrame(() => inputRef.current?.select()); return () => cancelAnimationFrame(frame); }, [open, value]); const trimmedSearch = searchValue.trim(); const matchesDetected = useMemo( () => detectedPlates.some( (plate) => plate.toLowerCase() === trimmedSearch.toLowerCase(), ), [detectedPlates, trimmedSearch], ); const showCustomOption = trimmedSearch.length > 0 && !matchesDetected; const commit = useCallback( (next: string) => { onCommit(next); setOpen(false); }, [onCommit], ); return ( {showCustomOption && ( commit(trimmedSearch)} > {t("configForm.knownPlates.useCustom", { ns: "views/settings", value: trimmedSearch, })} )} {detectedPlates.length > 0 ? ( {detectedPlates.map((plate) => { const assignedTo = plateAssignments.get(plate); const showAssignedTo = !!assignedTo && assignedTo !== entryName; return ( commit(plate)} > {plate} {showAssignedTo && ( {t("configForm.knownPlates.assignedTo", { ns: "views/settings", name: assignedTo, })} )} ); })} ) : ( !showCustomOption && (
{t("configForm.knownPlates.noneDetected", { ns: "views/settings", })}
) )}
); } export function KnownPlatesField(props: FieldProps) { const { schema, formData, onChange, idSchema, disabled, readonly } = props; const formContext = props.registry?.formContext as ConfigFormContext | undefined; const configNamespace = formContext?.i18nNamespace ?? (formContext?.level === "camera" ? "config/cameras" : "config/global"); const { t: fallbackT } = useTranslation(["common", configNamespace]); const t = formContext?.t ?? fallbackT; 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 id = idSchema?.$id ?? props.name; const sectionPrefix = formContext?.sectionI18nPrefix; const title = t(`${sectionPrefix}.${id}.label`) ?? (schema as RJSFSchema).title; const description = t(`${sectionPrefix}.${id}.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 { data: recognizedPlates } = useSWR( open ? ["recognized_license_plates", { split_joined: 1 }] : null, { revalidateOnFocus: false }, ); const detectedPlates = useMemo( () => recognizedPlates ?? [], [recognizedPlates], ); const plateAssignments = useMemo(() => { const assignments = new Map(); for (const [name, plates] of entries) { for (const plate of plates) { const trimmed = plate.trim(); if (trimmed && !assignments.has(trimmed)) { assignments.set(trimmed, name); } } } return assignments; }, [entries]); const [pendingOpenPlate, setPendingOpenPlate] = useState(null); const clearPendingOpenPlate = useCallback( () => setPendingOpenPlate(null), [], ); 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 plates = [...(data[key] || []), ""]; onChange({ ...data, [key]: plates }, fieldPath); setPendingOpenPlate(`${key}::${plates.length - 1}`); }, [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", }); return (
{title} {description && (

{description}

)}
{open ? ( ) : ( )}
{entries.map(([key, plates], entryIndex) => { const entryId = `${baseId}-${entryIndex}`; return (
handleRenameKey(key, next)} isKeyTaken={(next) => next !== key && Object.prototype.hasOwnProperty.call(data, next) } className="flex-1" />
{plates.map((plate, plateIndex) => (
handleUpdatePlate(key, plateIndex, next) } /> {plates.length > 1 && ( )}
))}
); })}
); } export default KnownPlatesField;