add ability to render components before and after fields

This commit is contained in:
Josh Hawkins 2026-01-31 20:44:53 -06:00
parent e09928a7f0
commit 3c5298e304
10 changed files with 320 additions and 92 deletions

View File

@ -153,7 +153,8 @@
"continue": "Continue", "continue": "Continue",
"modified": "Modified", "modified": "Modified",
"overridden": "Overridden", "overridden": "Overridden",
"resetToGlobal": "Reset to Global" "resetToGlobal": "Reset to Global",
"resetToDefault": "Reset to Default"
}, },
"menu": { "menu": {
"system": "System", "system": "System",

View File

@ -114,7 +114,7 @@
"desc": "Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one.", "desc": "Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one.",
"reindexNow": { "reindexNow": {
"label": "Reindex Now", "label": "Reindex Now",
"desc": "Reindexing will regenerate embeddings for all tracked object. This process runs in the background and may max out your CPU and take a fair amount of time depending on the number of tracked objects you have.", "desc": "Reindexing will regenerate embeddings for all tracked objects. This process runs in the background and may max out your CPU and take a fair amount of time depending on the number of tracked objects you have.",
"confirmTitle": "Confirm Reindexing", "confirmTitle": "Confirm Reindexing",
"confirmDesc": "Are you sure you want to reindex all tracked object embeddings? This process will run in the background but it may max out your CPU and take a fair amount of time. You can watch the progress on the Explore page.", "confirmDesc": "Are you sure you want to reindex all tracked object embeddings? This process will run in the background but it may max out your CPU and take a fair amount of time. You can watch the progress on the Explore page.",
"confirmButton": "Reindex", "confirmButton": "Reindex",

View File

@ -604,6 +604,11 @@ const sectionConfigs: Record<string, SectionConfigOverrides> = {
hiddenFields: [], hiddenFields: [],
advancedFields: [], advancedFields: [],
overrideFields: [], overrideFields: [],
uiSchema: {
enabled: {
"ui:after": { render: "SemanticSearchReindex" },
},
},
}, },
global: { global: {
fieldOrder: ["enabled", "reindex", "model", "model_size", "device"], fieldOrder: ["enabled", "reindex", "model", "model_size", "device"],

View File

@ -0,0 +1,106 @@
import { useState } from "react";
import axios from "axios";
import { Button, buttonVariants } from "@/components/ui/button";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export default function SemanticSearchReindex() {
const { t } = useTranslation("views/settings");
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const onReindex = async () => {
setIsLoading(true);
try {
const res = await axios.put("/reindex");
if (res.status === 202) {
toast.success(t("enrichments.semanticSearch.reindexNow.success"), {
position: "top-center",
});
} else {
toast.error(
t("enrichments.semanticSearch.reindexNow.error", {
errorMessage: res.statusText,
}),
{ position: "top-center" },
);
}
} catch (caught) {
const error = caught as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
error.response?.data?.message || error.response?.data?.detail || "";
toast.error(
t("enrichments.semanticSearch.reindexNow.error", {
errorMessage: errorMessage || undefined,
}),
{ position: "top-center" },
);
} finally {
setIsLoading(false);
}
};
return (
<>
<div className="flex flex-col space-y-1">
<div className="flex">
<Button
variant="default"
size="sm"
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
aria-label={t("enrichments.semanticSearch.reindexNow.label")}
>
{t("enrichments.semanticSearch.reindexNow.label")}
</Button>
</div>
<div className="mt-2 text-xs text-muted-foreground">
<Trans ns="views/settings">
enrichments.semanticSearch.reindexNow.desc
</Trans>
</div>
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("enrichments.semanticSearch.reindexNow.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
<Trans ns="views/settings">
enrichments.semanticSearch.reindexNow.confirmDesc
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setIsDialogOpen(false)}>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "select" })}
onClick={async () => {
await onReindex();
setIsDialogOpen(false);
}}
>
{t("enrichments.semanticSearch.reindexNow.confirmButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
);
}

View File

@ -0,0 +1,25 @@
import type { ComponentType } from "react";
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
export type RendererComponent = ComponentType<
Record<string, unknown> | undefined
>;
export type SectionRenderers = Record<
string,
Record<string, RendererComponent>
>;
// Section renderers registry
// Used to register custom renderer components for specific config sections.
// Maps a section key (e.g., `semantic_search`) to a mapping of renderer
// names to React components. These names are referenced from `uiSchema`
// descriptors (e.g., `{ "ui:after": { render: "SemanticSearchReindex" } }`) and
// are resolved by `FieldTemplate` through `formContext.renderers`.
export const sectionRenderers: SectionRenderers = {
semantic_search: {
SemanticSearchReindex,
},
};
export default sectionRenderers;

View File

@ -6,6 +6,9 @@ import useSWR from "swr";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import sectionRenderers, {
RendererComponent,
} from "@/components/config-form/sectionExtras/registry";
import { ConfigForm } from "../ConfigForm"; import { ConfigForm } from "../ConfigForm";
import type { UiSchema } from "@rjsf/utils"; import type { UiSchema } from "@rjsf/utils";
import { import {
@ -51,6 +54,8 @@ export interface SectionConfig {
liveValidate?: boolean; liveValidate?: boolean;
/** Additional uiSchema overrides */ /** Additional uiSchema overrides */
uiSchema?: UiSchema; uiSchema?: UiSchema;
/** Optional per-section renderers usable by FieldTemplate `ui:before`/`ui:after` */
renderers?: Record<string, RendererComponent>;
} }
export interface BaseSectionProps { export interface BaseSectionProps {
@ -418,32 +423,45 @@ export function createConfigSection({
updateTopic, updateTopic,
]); ]);
// Handle reset to global - removes camera-level override by deleting the section // Handle reset to global/defaults - removes camera-level override or resets global to defaults
const handleResetToGlobal = useCallback(async () => { const handleResetToGlobal = useCallback(async () => {
if (level !== "camera" || !cameraName) return; if (level === "camera" && !cameraName) return;
try { try {
const basePath = `cameras.${cameraName}.${sectionPath}`; const basePath =
level === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
// const configData = level === "global" ? schemaDefaults : "";
// Send empty string to delete the key from config (see update_yaml in backend)
// await axios.put("config/set", { // await axios.put("config/set", {
// requires_restart: requiresRestart ? 0 : 1, // requires_restart: requiresRestart ? 0 : 1,
// update_topic: updateTopic, // update_topic: updateTopic,
// config_data: { // config_data: {
// [basePath]: "", // [basePath]: configData,
// }, // },
// }); // });
// log reset to console for debugging // log reset to console for debugging
console.log("Reset to global config for path:", basePath, { console.log(
update_topic: updateTopic, level === "global"
requires_restart: requiresRestart ? 0 : 1, ? "Reset to defaults for path:"
}); : "Reset to global config for path:",
basePath,
{
update_topic: updateTopic,
requires_restart: requiresRestart ? 0 : 1,
},
);
toast.success( toast.success(
t("toast.resetSuccess", { t("toast.resetSuccess", {
ns: "views/settings", ns: "views/settings",
defaultValue: "Reset to global defaults", defaultValue:
level === "global"
? "Reset to defaults"
: "Reset to global defaults",
}), }),
); );
@ -519,6 +537,8 @@ export function createConfigSection({
// section prefix to templates so they can attempt `${section}.${field}` lookups. // section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionI18nPrefix: sectionPath, sectionI18nPrefix: sectionPath,
t, t,
renderers:
sectionConfig?.renderers ?? sectionRenderers?.[sectionPath],
}} }}
/> />
@ -593,7 +613,8 @@ export function createConfigSection({
</Badge> </Badge>
)} )}
</div> </div>
{level === "camera" && isOverridden && ( {((level === "camera" && isOverridden) ||
level === "global") && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -604,10 +625,15 @@ export function createConfigSection({
className="gap-2" className="gap-2"
> >
<LuRotateCcw className="h-4 w-4" /> <LuRotateCcw className="h-4 w-4" />
{t("button.resetToGlobal", { {level === "global"
ns: "common", ? t("button.resetToDefault", {
defaultValue: "Reset to Global", ns: "common",
})} defaultValue: "Reset to Default",
})
: t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button> </Button>
)} )}
</div> </div>
@ -650,7 +676,7 @@ export function createConfigSection({
</p> </p>
)} )}
</div> </div>
{level === "camera" && isOverridden && ( {((level === "camera" && isOverridden) || level === "global") && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -658,32 +684,43 @@ export function createConfigSection({
className="gap-2" className="gap-2"
> >
<LuRotateCcw className="h-4 w-4" /> <LuRotateCcw className="h-4 w-4" />
{t("button.resetToGlobal", { {level === "global"
ns: "common", ? t("button.resetToDefault", {
defaultValue: "Reset to Global", ns: "common",
})} defaultValue: "Reset to Default",
})
: t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button> </Button>
)} )}
</div> </div>
)} )}
{/* Reset button when title is hidden but we're at camera level with override */} {/* Reset button when title is hidden but we're at camera level with override */}
{!shouldShowTitle && level === "camera" && isOverridden && ( {!shouldShowTitle &&
<div className="flex justify-end"> ((level === "camera" && isOverridden) || level === "global") && (
<Button <div className="flex justify-end">
variant="ghost" <Button
size="sm" variant="ghost"
onClick={handleResetToGlobal} size="sm"
className="gap-2" onClick={handleResetToGlobal}
> className="gap-2"
<LuRotateCcw className="h-4 w-4" /> >
{t("button.resetToGlobal", { <LuRotateCcw className="h-4 w-4" />
ns: "common", {level === "global"
defaultValue: "Reset to Global", ? t("button.resetToDefault", {
})} ns: "common",
</Button> defaultValue: "Reset to Default",
</div> })
)} : t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
</div>
)}
{sectionContent} {sectionContent}
</div> </div>

View File

@ -30,8 +30,8 @@ export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
} }
return ( return (
<p id={id} className="text-sm text-muted-foreground"> <span id={id} className="text-sm text-muted-foreground">
{resolvedDescription} {resolvedDescription}
</p> </span>
); );
} }

View File

@ -5,6 +5,8 @@ import {
getUiOptions, getUiOptions,
ADDITIONAL_PROPERTY_FLAG, ADDITIONAL_PROPERTY_FLAG,
} from "@rjsf/utils"; } from "@rjsf/utils";
import { ComponentType, ReactNode } from "react";
import { isValidElement } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -52,6 +54,14 @@ function humanizeKey(value: string): string {
.join(" "); .join(" ");
} }
type FieldRenderSpec =
| ReactNode
| ComponentType<unknown>
| {
render: string;
props?: Record<string, unknown>;
};
export function FieldTemplate(props: FieldTemplateProps) { export function FieldTemplate(props: FieldTemplateProps) {
const { const {
id, id,
@ -264,6 +274,41 @@ export function FieldTemplate(props: FieldTemplateProps) {
} }
const uiOptions = getUiOptions(uiSchema); const uiOptions = getUiOptions(uiSchema);
const beforeSpec = uiSchema?.["ui:before"] as FieldRenderSpec | undefined;
const afterSpec = uiSchema?.["ui:after"] as FieldRenderSpec | undefined;
const renderCustom = (spec: FieldRenderSpec | undefined) => {
if (spec === undefined || spec === null) {
return null;
}
if (isValidElement(spec) || typeof spec === "string") {
return spec;
}
if (typeof spec === "number") {
return <span>{spec}</span>;
}
if (typeof spec === "function") {
const SpecComponent = spec as ComponentType<unknown>;
return <SpecComponent />;
}
if (typeof spec === "object" && "render" in spec) {
const renderKey = spec.render;
const renderers = formContext?.renderers;
const RenderComponent = renderers?.[renderKey];
if (RenderComponent) {
return <RenderComponent {...(spec.props ?? {})} />;
}
}
return null;
};
const beforeContent = renderCustom(beforeSpec);
const afterContent = renderCustom(afterSpec);
const WrapIfAdditionalTemplate = getTemplate( const WrapIfAdditionalTemplate = getTemplate(
"WrapIfAdditionalTemplate", "WrapIfAdditionalTemplate",
registry, registry,
@ -290,64 +335,71 @@ export function FieldTemplate(props: FieldTemplateProps) {
rawErrors={rawErrors} rawErrors={rawErrors}
hideError={false} hideError={false}
> >
<div <div className="flex flex-col space-y-6">
className={cn( {beforeContent}
"space-y-1", <div
isAdvanced && "border-l-2 border-muted pl-4", className={cn(
isBoolean && "flex items-center justify-between gap-4", "space-y-1",
)} isAdvanced && "border-l-2 border-muted pl-4",
data-field-id={translationPath} isBoolean && "flex items-center justify-between gap-4",
>
{displayLabel &&
finalLabel &&
!isBoolean &&
!isMultiSchemaWrapper &&
!isObjectField &&
!isAdditionalProperty && (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
errors &&
errors.props?.errors?.length > 0 &&
"text-destructive",
)}
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)} )}
data-field-id={translationPath}
>
{displayLabel &&
finalLabel &&
!isBoolean &&
!isMultiSchemaWrapper &&
!isObjectField &&
!isAdditionalProperty && (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
errors &&
errors.props?.errors?.length > 0 &&
"text-destructive",
)}
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{isBoolean ? ( {isBoolean ? (
<div className="flex w-full items-center justify-between gap-4"> <div className="flex w-full items-center justify-between gap-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
{displayLabel && finalLabel && ( {displayLabel && finalLabel && (
<Label htmlFor={id} className="text-sm font-medium"> <Label htmlFor={id} className="text-sm font-medium">
{finalLabel} {finalLabel}
{required && <span className="ml-1 text-destructive">*</span>} {required && (
</Label> <span className="ml-1 text-destructive">*</span>
)} )}
{finalDescription && !isMultiSchemaWrapper && ( </Label>
)}
{finalDescription && !isMultiSchemaWrapper && (
<p className="text-xs text-muted-foreground">
{finalDescription}
</p>
)}
</div>
<div className="flex items-center gap-2">{children}</div>
</div>
) : (
<>
{children}
{finalDescription && !isMultiSchemaWrapper && !isObjectField && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{finalDescription} {finalDescription}
</p> </p>
)} )}
</div> </>
{children} )}
</div>
) : (
<>
{children}
{finalDescription && !isMultiSchemaWrapper && !isObjectField && (
<p className="text-xs text-muted-foreground">
{finalDescription}
</p>
)}
</>
)}
{errors} {errors}
{help} {help}
</div>
{afterContent}
</div> </div>
</WrapIfAdditionalTemplate> </WrapIfAdditionalTemplate>
); );

View File

@ -221,7 +221,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
<div className="space-y-6"> <div className="space-y-6">
{groups.map((group) => ( {groups.map((group) => (
<div key={group.key} className="space-y-4"> <div key={group.key} className="space-y-4">
<div className="text-sm font-medium text-muted-foreground"> <div className="text-md font-medium text-primary">
{group.label} {group.label}
</div> </div>
<div className="space-y-4"> <div className="space-y-4">

View File

@ -1,3 +1,4 @@
import type { RendererComponent } from "@/components/config-form/sectionExtras/registry";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
export type JsonPrimitive = string | number | boolean | null; export type JsonPrimitive = string | number | boolean | null;
@ -22,4 +23,5 @@ export type ConfigFormContext = {
i18nNamespace?: string; i18nNamespace?: string;
sectionI18nPrefix?: string; sectionI18nPrefix?: string;
t?: (key: string, options?: Record<string, unknown>) => string; t?: (key: string, options?: Record<string, unknown>) => string;
renderers?: Record<string, RendererComponent>;
}; };