From 3042c36168d35d3f9cde06122da48b968cb8389a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:23:43 -0600 Subject: [PATCH] add replace rules field --- .../config-form/section-configs/lpr.ts | 9 + .../theme/fields/ReplaceRulesField.tsx | 253 ++++++++++++++++++ .../config-form/theme/fields/index.ts | 1 + .../config-form/theme/frigateTheme.ts | 2 + .../theme/templates/FieldTemplate.tsx | 5 +- 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 web/src/components/config-form/theme/fields/ReplaceRulesField.tsx diff --git a/web/src/components/config-form/section-configs/lpr.ts b/web/src/components/config-form/section-configs/lpr.ts index d5f248b26..4089f031d 100644 --- a/web/src/components/config-form/section-configs/lpr.ts +++ b/web/src/components/config-form/section-configs/lpr.ts @@ -40,6 +40,15 @@ const lpr: SectionConfigOverrides = { "device", "replace_rules", ], + uiSchema: { + replace_rules: { + "ui:field": "ReplaceRulesField", + "ui:options": { + label: false, + suppressDescription: true, + }, + }, + }, }, }; diff --git a/web/src/components/config-form/theme/fields/ReplaceRulesField.tsx b/web/src/components/config-form/theme/fields/ReplaceRulesField.tsx new file mode 100644 index 000000000..6724854c9 --- /dev/null +++ b/web/src/components/config-form/theme/fields/ReplaceRulesField.tsx @@ -0,0 +1,253 @@ +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 { Label } from "@/components/ui/label"; +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 ReplaceRule = { + pattern?: string; + replacement?: string; +}; + +function getItemSchema(schema: RJSFSchema): RJSFSchema | undefined { + const items = schema.items; + if (!items || typeof items !== "object" || Array.isArray(items)) { + return undefined; + } + return items as RJSFSchema; +} + +function getPropertyTitle(itemSchema: RJSFSchema | undefined, key: string) { + const props = (itemSchema as { properties?: Record }) + ?.properties; + const title = props?.[key]?.title; + return typeof title === "string" ? title : undefined; +} + +export function ReplaceRulesField(props: FieldProps) { + const { schema, formData, onChange, idSchema, disabled, readonly } = props; + const formContext = props.registry?.formContext as + | ConfigFormContext + | undefined; + + const { t } = useTranslation(["common"]); + + const rules: ReplaceRule[] = useMemo(() => { + if (!Array.isArray(formData)) { + return []; + } + return formData as ReplaceRule[]; + }, [formData]); + + const itemSchema = useMemo( + () => getItemSchema(schema as RJSFSchema), + [schema], + ); + const title = (schema as RJSFSchema).title; + const description = (schema as RJSFSchema).description; + const patternTitle = getPropertyTitle(itemSchema, "pattern"); + const replacementTitle = getPropertyTitle(itemSchema, "replacement"); + + const hasItems = rules.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( + rules, + baselineValue, + formContext?.overrides, + fieldPath, + formContext?.formData, + ); + }, [fieldPath, formContext, rules]); + + const [open, setOpen] = useState(hasItems || isModified); + + useEffect(() => { + if (isModified) { + setOpen(true); + } + }, [isModified]); + + useEffect(() => { + if (hasItems) { + setOpen(true); + } + }, [hasItems]); + + const handleAdd = useCallback(() => { + const next = [...rules, { pattern: "", replacement: "" }]; + onChange(next, fieldPath); + }, [fieldPath, onChange, rules]); + + const handleRemove = useCallback( + (index: number) => { + const next = rules.filter((_, i) => i !== index); + onChange(next, fieldPath); + }, + [fieldPath, onChange, rules], + ); + + const handleUpdate = useCallback( + (index: number, patch: Partial) => { + const next = rules.map((rule, i) => { + if (i !== index) { + return rule; + } + return { + ...rule, + ...patch, + }; + }); + onChange(next, fieldPath); + }, + [fieldPath, onChange, rules], + ); + + const baseId = idSchema?.$id || "replace_rules"; + const deleteLabel = t("button.delete", { + ns: "common", + defaultValue: "Delete", + }); + + return ( + + + + +
+
+ + {title} + + {description && ( +

+ {description} +

+ )} +
+ {open ? ( + + ) : ( + + )} +
+
+
+ + + + {rules.length > 0 && ( +
+
+ {patternTitle && ( + + )} +
+
+ {replacementTitle && ( + + )} +
+
+ )} + +
+ {rules.map((rule, index) => { + const patternId = `${baseId}-${index}-pattern`; + const replacementId = `${baseId}-${index}-replacement`; + + return ( +
+
+ + handleUpdate(index, { pattern: e.target.value }) + } + /> +
+
+ + handleUpdate(index, { replacement: e.target.value }) + } + /> +
+
+ +
+
+ ); + })} +
+ +
+ +
+
+
+
+
+ ); +} + +export default ReplaceRulesField; diff --git a/web/src/components/config-form/theme/fields/index.ts b/web/src/components/config-form/theme/fields/index.ts index 27b34acb0..b6b707866 100644 --- a/web/src/components/config-form/theme/fields/index.ts +++ b/web/src/components/config-form/theme/fields/index.ts @@ -1,3 +1,4 @@ // Custom RJSF Fields export { LayoutGridField } from "./LayoutGridField"; export { DetectorHardwareField } from "./DetectorHardwareField"; +export { ReplaceRulesField } from "./ReplaceRulesField"; diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index 04103154d..d2d6c5cc3 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -38,6 +38,7 @@ import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate"; import { LayoutGridField } from "./fields/LayoutGridField"; import { DetectorHardwareField } from "./fields/DetectorHardwareField"; +import { ReplaceRulesField } from "./fields/ReplaceRulesField"; export interface FrigateTheme { widgets: RegistryWidgetsType; @@ -83,5 +84,6 @@ export const frigateTheme: FrigateTheme = { fields: { LayoutGridField: LayoutGridField, DetectorHardwareField: DetectorHardwareField, + ReplaceRulesField: ReplaceRulesField, }, }; diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx index 12a722587..c8f3ae285 100644 --- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx +++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx @@ -98,6 +98,8 @@ export function FieldTemplate(props: FieldTemplateProps) { // Get UI options const uiOptionsFromSchema = uiSchema?.["ui:options"] || {}; + const suppressDescription = uiOptionsFromSchema.suppressDescription === true; + // Determine field characteristics const isAdvanced = uiOptionsFromSchema.advanced === true; const isBoolean = @@ -130,7 +132,8 @@ export function FieldTemplate(props: FieldTemplateProps) { !isMultiSchemaWrapper && !isObjectField && !isAdditionalProperty && - !isArrayItemInAdditionalProp; + !isArrayItemInAdditionalProp && + !suppressDescription; const translationPath = buildTranslationPath( pathSegments,