mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-27 06:09:01 +03:00
Full UI configuration (#22151)
* use react-jsonschema-form for UI config * don't use properties wrapper when generating config i18n json * configure for full i18n support * section fields * add descriptions to all fields for i18n * motion i18n * fix nullable fields * sanitize internal fields * add switches widgets and use friendly names * fix nullable schema entries * ensure update_topic is added to api calls this needs further backend implementation to work correctly * add global sections, camera config overrides, and reset button * i18n * add reset logic to global config view * tweaks * fix sections and live validation * fix validation for schema objects that can be null * generic and custom per-field validation * improve generic error validation messages * remove show advanced fields switch * tweaks * use shadcn theme * fix array field template * i18n tweaks * remove collapsible around root section * deep merge schema for advanced fields * add array field item template and fix ffmpeg section * add missing i18n keys * tweaks * comment out api call for testing * add config groups as a separate i18n namespace * add descriptions to all pydantic fields * make titles more concise * new titles as i18n * update i18n config generation script to use json schema * tweaks * tweaks * rebase * clean up * form tweaks * add wildcards and fix object filter fields * add field template for additionalproperties schema objects * improve typing * add section description from schema and clarify global vs camera level descriptions * separate and consolidate global and camera i18n namespaces * clean up now obsolete namespaces * tweaks * refactor sections and overrides * add ability to render components before and after fields * fix titles * chore(sections): remove legacy single-section components replaced by template * refactor configs to use individual files with a template * fix review description * apply hidden fields after ui schema * move util * remove unused i18n * clean up error messages * fix fast refresh * add custom validation and use it for ffmpeg input roles * update nav tree * remove unused * re-add override and modified indicators * mark pending changes and add confirmation dialog for resets * fix red unsaved dot * tweaks * add docs links, readonly keys, and restart required per field * add special case and comments for global motion section * add section form special cases * combine review sections * tweaks * add audio labels endpoint * add audio label switches and input to filter list * fix type * remove key from config when resetting to default/global * don't show description for new key/val fields * tweaks * spacing tweaks * add activity indicator and scrollbar tweaks * add docs to filter fields * wording changes * fix global ffmpeg section * add review classification zones to review form * add backend endpoint and frontend widget for ffmpeg presets and manual args * improve wording * hide descriptions for additional properties arrays * add warning log about incorrectly nested model config * spacing and language tweaks * fix i18n keys * networking section docs and description * small wording tweaks * add layout grid field * refactor with shared utilities * field order * add individual detectors to schema add detector titles and descriptions (docstrings in pydantic are used for descriptions) and add i18n keys to globals * clean up detectors section and i18n * don't save model config back to yaml when saving detectors * add full detectors config to api model dump works around the way we use detector plugins so we can have the full detector config for the frontend * add restart button to toast when restart is required * add ui option to remove inner cards * fix buttons * section tweaks * don't zoom into text on mobile * make buttons sticky at bottom of sections * small tweaks * highlight label of changed fields * add null to enum list when unwrapping * refactor to shared utils and add save all button * add undo all button * add RJSF to dictionary * consolidate utils * preserve form data when changing cameras * add mono fonts * add popover to show what fields will be saved * fix mobile menu not re-rendering with unsaved dots * tweaks * fix logger and env vars config section saving use escaped periods in keys to retain them in the config file (eg "frigate.embeddings") * add timezone widget * role map field with validation * fix validation for model section * add another hidden field * add footer message for required restart * use rjsf for notifications view * fix config saving * add replace rules field * default column layout and add field sizing * clean up field template * refactor profile settings to match rjsf forms * tweaks * refactor frigate+ view and make tweaks to sections * show frigate+ model info in detection model settings when using a frigate+ model * update restartRequired for all fields * fix restart fields * tweaks and add ability enable disabled cameras more backend changes required * require restart when enabling camera that is disabled in config * disable save when form is invalid * refactor ffmpeg section for readability * change label * clean up camera inputs fields * misc tweaks to ffmpeg section - add raw paths endpoint to ensure credentials get saved - restart required tooltip * maintenance settings tweaks * don't mutate with lodash * fix description re-rendering for nullable object fields * hide reindex field * update rjsf * add frigate+ description to settings pane * disable save all when any section is invalid * show translated field name in validation error pane * clean up * remove unused * fix genai merge * fix genai
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Shared UI components for config form templates and fields.
|
||||
*/
|
||||
|
||||
import { canExpand } from "@rjsf/utils";
|
||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuPlus, LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface AddPropertyButtonProps {
|
||||
/** Callback fired when the add button is clicked */
|
||||
onAddProperty?: () => void;
|
||||
/** JSON Schema to determine expandability */
|
||||
schema: RJSFSchema;
|
||||
/** UI Schema for expansion checks */
|
||||
uiSchema?: UiSchema;
|
||||
/** Current form data for expansion checks */
|
||||
formData?: unknown;
|
||||
/** Whether the form is disabled */
|
||||
disabled?: boolean;
|
||||
/** Whether the form is read-only */
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add property button for RJSF objects with additionalProperties.
|
||||
* Shows "Add" button that allows adding new key-value pairs to objects.
|
||||
*/
|
||||
export function AddPropertyButton({
|
||||
onAddProperty,
|
||||
schema,
|
||||
uiSchema,
|
||||
formData,
|
||||
disabled,
|
||||
readonly,
|
||||
}: AddPropertyButtonProps) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const canAdd =
|
||||
Boolean(onAddProperty) && canExpand(schema, uiSchema, formData);
|
||||
|
||||
if (!canAdd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAddProperty}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", { ns: "common", defaultValue: "Add" })}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdvancedCollapsibleProps {
|
||||
/** Number of advanced fields */
|
||||
count: number;
|
||||
/** Whether the collapsible is open */
|
||||
open: boolean;
|
||||
/** Callback when open state changes */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Content to show when expanded */
|
||||
children: ReactNode;
|
||||
/** Use root-level label variant (longer text) */
|
||||
isRoot?: boolean;
|
||||
/** Button size - defaults to undefined (default) for root, "sm" for nested */
|
||||
buttonSize?: "sm" | "default" | "lg" | "icon";
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible section for advanced form fields.
|
||||
* Provides consistent styling and i18n labels for advanced settings.
|
||||
*/
|
||||
export function AdvancedCollapsible({
|
||||
count,
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
isRoot = false,
|
||||
buttonSize,
|
||||
}: AdvancedCollapsibleProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveSize = buttonSize ?? (isRoot ? undefined : "sm");
|
||||
|
||||
const label = isRoot
|
||||
? t("configForm.advancedSettingsCount", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Advanced Settings ({{count}})",
|
||||
count,
|
||||
})
|
||||
: t("configForm.advancedCount", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Advanced ({{count}})",
|
||||
count,
|
||||
});
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={effectiveSize}
|
||||
className="w-full justify-start gap-2 pl-0 hover:bg-transparent"
|
||||
>
|
||||
{open ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-4 rounded-lg border border-border/60 bg-secondary/40 p-4">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import type {
|
||||
ErrorSchema,
|
||||
FieldProps,
|
||||
RJSFSchema,
|
||||
UiSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { toFieldPathId } from "@rjsf/utils";
|
||||
import { cloneDeep, isEqual } from "lodash";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { mergeUiSchema } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
type FfmpegInput = {
|
||||
path?: string;
|
||||
roles?: string[];
|
||||
hwaccel_args?: unknown;
|
||||
};
|
||||
|
||||
const asInputList = (formData: unknown): FfmpegInput[] => {
|
||||
if (!Array.isArray(formData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return formData.filter(
|
||||
(item): item is FfmpegInput => typeof item === "object" && item !== null,
|
||||
);
|
||||
};
|
||||
|
||||
const getItemSchema = (schema: RJSFSchema): RJSFSchema | undefined => {
|
||||
const items = schema.items;
|
||||
if (!items || typeof items !== "object" || Array.isArray(items)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items as RJSFSchema;
|
||||
};
|
||||
|
||||
const getItemProperties = (
|
||||
schema: RJSFSchema | undefined,
|
||||
): Record<string, RJSFSchema> => {
|
||||
if (!schema || typeof schema.properties !== "object" || !schema.properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return schema.properties as Record<string, RJSFSchema>;
|
||||
};
|
||||
|
||||
const hasDetectRole = (input: FfmpegInput): boolean =>
|
||||
Array.isArray(input.roles) && input.roles.includes("detect");
|
||||
|
||||
const hasHwaccelValue = (value: unknown): boolean => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const normalizeNonDetectHwaccel = (inputs: FfmpegInput[]): FfmpegInput[] =>
|
||||
inputs.map((input) => {
|
||||
if (hasDetectRole(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (!hasHwaccelValue(input.hwaccel_args)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
hwaccel_args: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
export function CameraInputsField(props: FieldProps) {
|
||||
const {
|
||||
schema,
|
||||
uiSchema,
|
||||
formData,
|
||||
onChange,
|
||||
fieldPathId,
|
||||
registry,
|
||||
idSchema,
|
||||
errorSchema,
|
||||
disabled,
|
||||
readonly,
|
||||
hideError,
|
||||
onBlur,
|
||||
onFocus,
|
||||
} = props;
|
||||
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : "config/global";
|
||||
|
||||
const { t, i18n } = useTranslation([
|
||||
"common",
|
||||
"views/settings",
|
||||
effectiveNamespace,
|
||||
]);
|
||||
|
||||
const inputs = useMemo(() => asInputList(formData), [formData]);
|
||||
const arraySchema = schema as RJSFSchema;
|
||||
const itemSchema = useMemo(() => getItemSchema(arraySchema), [arraySchema]);
|
||||
const itemProperties = useMemo(
|
||||
() => getItemProperties(itemSchema),
|
||||
[itemSchema],
|
||||
);
|
||||
const itemUiSchema = useMemo(
|
||||
() =>
|
||||
((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema,
|
||||
[uiSchema],
|
||||
);
|
||||
const SchemaField = registry.fields.SchemaField;
|
||||
|
||||
const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setOpenByIndex((previous) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
for (let index = 0; index < inputs.length; index += 1) {
|
||||
next[index] = previous[index] ?? true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [inputs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = normalizeNonDetectHwaccel(inputs);
|
||||
if (!isEqual(normalized, inputs)) {
|
||||
onChange(normalized, fieldPathId.path);
|
||||
}
|
||||
}, [fieldPathId.path, inputs, onChange]);
|
||||
|
||||
const handleFieldValueChange = useCallback(
|
||||
(index: number, fieldName: string, nextValue: unknown) => {
|
||||
const nextInputs = cloneDeep(inputs);
|
||||
const item =
|
||||
(nextInputs[index] as Record<string, unknown> | undefined) ??
|
||||
({} as Record<string, unknown>);
|
||||
|
||||
item[fieldName] = nextValue;
|
||||
nextInputs[index] = item;
|
||||
|
||||
onChange(normalizeNonDetectHwaccel(nextInputs), fieldPathId.path);
|
||||
},
|
||||
[fieldPathId.path, inputs, onChange],
|
||||
);
|
||||
|
||||
const handleAddInput = useCallback(() => {
|
||||
const base = itemSchema
|
||||
? (applySchemaDefaults(itemSchema) as FfmpegInput)
|
||||
: ({} as FfmpegInput);
|
||||
const nextInputs = normalizeNonDetectHwaccel([...inputs, base]);
|
||||
onChange(nextInputs, fieldPathId.path);
|
||||
setOpenByIndex((previous) => ({ ...previous, [inputs.length]: true }));
|
||||
}, [fieldPathId.path, inputs, itemSchema, onChange]);
|
||||
|
||||
const handleRemoveInput = useCallback(
|
||||
(index: number) => {
|
||||
const nextInputs = inputs.filter(
|
||||
(_, currentIndex) => currentIndex !== index,
|
||||
);
|
||||
onChange(nextInputs, fieldPathId.path);
|
||||
setOpenByIndex((previous) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
const current = Number(key);
|
||||
if (Number.isNaN(current) || current === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
next[current > index ? current - 1 : current] = value;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[fieldPathId.path, inputs, onChange],
|
||||
);
|
||||
|
||||
const renderField = useCallback(
|
||||
(
|
||||
index: number,
|
||||
fieldName: string,
|
||||
options?: {
|
||||
extraUiSchema?: UiSchema;
|
||||
showSchemaDescription?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (!SchemaField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldSchema = itemProperties[fieldName];
|
||||
if (!fieldSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemData = inputs[index] as Record<string, unknown>;
|
||||
const itemPath = [...fieldPathId.path, index];
|
||||
const itemFieldPathId = toFieldPathId(
|
||||
fieldName,
|
||||
registry.globalFormOptions,
|
||||
itemPath,
|
||||
);
|
||||
|
||||
const itemErrors = (
|
||||
errorSchema as Record<string, ErrorSchema> | undefined
|
||||
)?.[index] as Record<string, ErrorSchema> | undefined;
|
||||
const fieldErrorSchema = itemErrors?.[fieldName];
|
||||
|
||||
const baseUiSchema =
|
||||
(itemUiSchema[fieldName] as UiSchema | undefined) ?? ({} as UiSchema);
|
||||
const mergedUiSchema = options?.extraUiSchema
|
||||
? mergeUiSchema(baseUiSchema, options.extraUiSchema)
|
||||
: baseUiSchema;
|
||||
|
||||
const fieldTranslationDescriptionKey = `ffmpeg.inputs.${fieldName}.description`;
|
||||
const translatedDescription = i18n.exists(
|
||||
fieldTranslationDescriptionKey,
|
||||
{
|
||||
ns: effectiveNamespace,
|
||||
},
|
||||
)
|
||||
? t(fieldTranslationDescriptionKey, { ns: effectiveNamespace })
|
||||
: "";
|
||||
|
||||
const fieldDescription =
|
||||
typeof fieldSchema.description === "string" &&
|
||||
fieldSchema.description.length > 0
|
||||
? fieldSchema.description
|
||||
: translatedDescription;
|
||||
|
||||
const handleScopedFieldChange = (
|
||||
nextValue: unknown,
|
||||
_path: unknown,
|
||||
_errors?: ErrorSchema,
|
||||
_id?: string,
|
||||
) => {
|
||||
handleFieldValueChange(index, fieldName, nextValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<SchemaField
|
||||
name={fieldName}
|
||||
schema={fieldSchema}
|
||||
uiSchema={mergedUiSchema}
|
||||
fieldPathId={itemFieldPathId}
|
||||
formData={itemData?.[fieldName]}
|
||||
errorSchema={fieldErrorSchema}
|
||||
onChange={handleScopedFieldChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
registry={registry}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
hideError={hideError}
|
||||
/>
|
||||
{options?.showSchemaDescription && fieldDescription ? (
|
||||
<p className="text-xs text-muted-foreground">{fieldDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
SchemaField,
|
||||
itemProperties,
|
||||
inputs,
|
||||
fieldPathId.path,
|
||||
registry,
|
||||
errorSchema,
|
||||
itemUiSchema,
|
||||
i18n,
|
||||
handleFieldValueChange,
|
||||
effectiveNamespace,
|
||||
onBlur,
|
||||
onFocus,
|
||||
disabled,
|
||||
readonly,
|
||||
hideError,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const baseId = idSchema?.$id ?? "ffmpeg_inputs";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{inputs.map((input, index) => {
|
||||
const open = openByIndex[index] ?? true;
|
||||
const itemTitle = t("configForm.cameraInputs.itemTitle", {
|
||||
ns: "views/settings",
|
||||
index: index + 1,
|
||||
});
|
||||
const itemPath =
|
||||
typeof input.path === "string" ? input.path.trim() : "";
|
||||
|
||||
return (
|
||||
<Card key={`${baseId}-${index}`} className="w-full">
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setOpenByIndex((previous) => ({
|
||||
...previous,
|
||||
[index]: nextOpen,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<CardTitle className="text-sm">
|
||||
<span>{itemTitle}</span>
|
||||
{itemPath ? (
|
||||
<span className="mt-1 block text-xs font-normal text-muted-foreground">
|
||||
{itemPath}
|
||||
</span>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
{open ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4 p-4 pt-0">
|
||||
<div className="w-full">
|
||||
{renderField(index, "path", {
|
||||
extraUiSchema: {
|
||||
"ui:widget": "CameraPathWidget",
|
||||
"ui:options": {
|
||||
size: "full",
|
||||
splitLayout: false,
|
||||
},
|
||||
},
|
||||
showSchemaDescription: true,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-full">{renderField(index, "roles")}</div>
|
||||
|
||||
{renderField(index, "input_args")}
|
||||
|
||||
{hasDetectRole(input)
|
||||
? renderField(index, "hwaccel_args", {
|
||||
extraUiSchema: {
|
||||
"ui:options": {
|
||||
allowInherit: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null}
|
||||
|
||||
{renderField(index, "output_args")}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveInput(index)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddInput}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CameraInputsField;
|
||||
@@ -0,0 +1,891 @@
|
||||
import type {
|
||||
ErrorSchema,
|
||||
FieldPathList,
|
||||
FieldProps,
|
||||
RJSFSchema,
|
||||
UiSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { toFieldPathId } from "@rjsf/utils";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { cn, isJsonObject, mergeUiSchema } from "@/lib/utils";
|
||||
import { ConfigFormContext, JsonObject } from "@/types/configForm";
|
||||
import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { humanizeKey } from "../utils/i18n";
|
||||
|
||||
type DetectorHardwareFieldOptions = {
|
||||
multiInstanceTypes?: string[];
|
||||
hiddenByType?: Record<string, string[]>;
|
||||
hiddenFields?: string[];
|
||||
typeOrder?: string[];
|
||||
};
|
||||
|
||||
type DetectorSchemaEntry = {
|
||||
type: string;
|
||||
schema: RJSFSchema;
|
||||
};
|
||||
|
||||
const DEFAULT_MULTI_INSTANCE_TYPES = ["cpu", "onnx", "openvino"];
|
||||
const EMPTY_HIDDEN_BY_TYPE: Record<string, string[]> = {};
|
||||
const EMPTY_HIDDEN_FIELDS: string[] = [];
|
||||
const EMPTY_TYPE_ORDER: string[] = [];
|
||||
|
||||
const isSchemaObject = (schema: unknown): schema is RJSFSchema =>
|
||||
typeof schema === "object" && schema !== null;
|
||||
|
||||
const getUnionSchemas = (schema?: RJSFSchema): RJSFSchema[] => {
|
||||
if (!schema) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
const union = schemaObj.oneOf ?? schemaObj.anyOf;
|
||||
if (Array.isArray(union)) {
|
||||
return union.filter(isSchemaObject) as RJSFSchema[];
|
||||
}
|
||||
|
||||
return [schema];
|
||||
};
|
||||
|
||||
const getTypeValues = (schema: RJSFSchema): string[] => {
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
const properties = schemaObj.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const typeSchema = properties?.type as Record<string, unknown> | undefined;
|
||||
const values: string[] = [];
|
||||
|
||||
if (typeof typeSchema?.const === "string") {
|
||||
values.push(typeSchema.const);
|
||||
}
|
||||
|
||||
if (Array.isArray(typeSchema?.enum)) {
|
||||
typeSchema.enum.forEach((value) => {
|
||||
if (typeof value === "string") {
|
||||
values.push(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
const buildHiddenUiSchema = (paths: string[]): UiSchema => {
|
||||
const result: UiSchema = {};
|
||||
|
||||
paths.forEach((path) => {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = path.split(".").filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cursor = result;
|
||||
segments.forEach((segment, index) => {
|
||||
if (index === segments.length - 1) {
|
||||
cursor[segment] = {
|
||||
...(cursor[segment] as UiSchema | undefined),
|
||||
"ui:widget": "hidden",
|
||||
} as UiSchema;
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = (cursor[segment] as UiSchema | undefined) ?? {};
|
||||
cursor[segment] = existing;
|
||||
cursor = existing;
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getInstanceType = (value: unknown): string | undefined => {
|
||||
if (!isJsonObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const typeValue = value.type;
|
||||
return typeof typeValue === "string" && typeValue.length > 0
|
||||
? typeValue
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export function DetectorHardwareField(props: FieldProps) {
|
||||
const {
|
||||
schema,
|
||||
uiSchema,
|
||||
registry,
|
||||
fieldPathId,
|
||||
formData: rawFormData,
|
||||
errorSchema,
|
||||
disabled,
|
||||
readonly,
|
||||
hideError,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
const formContext = 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 sectionPrefix = formContext?.sectionI18nPrefix ?? "detectors";
|
||||
const restartRequired = formContext?.restartRequired;
|
||||
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
|
||||
|
||||
const options =
|
||||
(uiSchema?.["ui:options"] as DetectorHardwareFieldOptions | undefined) ??
|
||||
{};
|
||||
const multiInstanceTypes =
|
||||
options.multiInstanceTypes ?? DEFAULT_MULTI_INSTANCE_TYPES;
|
||||
const hiddenByType = options.hiddenByType ?? EMPTY_HIDDEN_BY_TYPE;
|
||||
const hiddenFields = options.hiddenFields ?? EMPTY_HIDDEN_FIELDS;
|
||||
const typeOrder = options.typeOrder ?? EMPTY_TYPE_ORDER;
|
||||
const multiInstanceSet = useMemo(
|
||||
() => new Set(multiInstanceTypes),
|
||||
[multiInstanceTypes],
|
||||
);
|
||||
const globalHiddenFields = useMemo(
|
||||
() =>
|
||||
hiddenFields
|
||||
.map((path) => (path.startsWith("*.") ? path.slice(2) : path))
|
||||
.filter((path) => path.length > 0),
|
||||
[hiddenFields],
|
||||
);
|
||||
|
||||
const detectorConfigSchema = useMemo(() => {
|
||||
const additional = (schema as RJSFSchema | undefined)?.additionalProperties;
|
||||
if (isSchemaObject(additional)) {
|
||||
return additional as RJSFSchema;
|
||||
}
|
||||
|
||||
const rootSchema = registry.rootSchema as Record<string, unknown>;
|
||||
const defs =
|
||||
(rootSchema?.$defs as Record<string, unknown> | undefined) ??
|
||||
(rootSchema?.definitions as Record<string, unknown> | undefined);
|
||||
const fallback = defs?.DetectorConfig;
|
||||
|
||||
return isSchemaObject(fallback) ? (fallback as RJSFSchema) : undefined;
|
||||
}, [schema, registry.rootSchema]);
|
||||
|
||||
const detectorSchemas = useMemo<DetectorSchemaEntry[]>(() => {
|
||||
const entries: DetectorSchemaEntry[] = [];
|
||||
getUnionSchemas(detectorConfigSchema).forEach((schema) => {
|
||||
const types = getTypeValues(schema);
|
||||
types.forEach((type) => {
|
||||
entries.push({ type, schema });
|
||||
});
|
||||
});
|
||||
return entries;
|
||||
}, [detectorConfigSchema]);
|
||||
|
||||
const detectorSchemaByType = useMemo(() => {
|
||||
const map = new Map<string, RJSFSchema>();
|
||||
detectorSchemas.forEach(({ type, schema }) => {
|
||||
if (!map.has(type)) {
|
||||
map.set(type, schema);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [detectorSchemas]);
|
||||
|
||||
const availableTypes = useMemo(
|
||||
() => detectorSchemas.map((entry) => entry.type),
|
||||
[detectorSchemas],
|
||||
);
|
||||
|
||||
const orderedTypes = useMemo(() => {
|
||||
if (!typeOrder.length) {
|
||||
return availableTypes;
|
||||
}
|
||||
|
||||
const availableSet = new Set(availableTypes);
|
||||
const ordered = typeOrder.filter((type) => availableSet.has(type));
|
||||
const orderedSet = new Set(ordered);
|
||||
const remaining = availableTypes.filter((type) => !orderedSet.has(type));
|
||||
return [...ordered, ...remaining];
|
||||
}, [availableTypes, typeOrder]);
|
||||
|
||||
const formData = isJsonObject(rawFormData) ? rawFormData : {};
|
||||
const detectors = formData as JsonObject;
|
||||
|
||||
const [addType, setAddType] = useState<string | undefined>(orderedTypes[0]);
|
||||
const [addError, setAddError] = useState<string | undefined>();
|
||||
const [renameDrafts, setRenameDrafts] = useState<Record<string, string>>({});
|
||||
const [renameErrors, setRenameErrors] = useState<Record<string, string>>({});
|
||||
const [typeErrors, setTypeErrors] = useState<Record<string, string>>({});
|
||||
const [openKeys, setOpenKeys] = useState<Set<string>>(
|
||||
() => new Set(Object.keys(detectors)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderedTypes.length) {
|
||||
setAddType(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!addType || !orderedTypes.includes(addType)) {
|
||||
setAddType(orderedTypes[0]);
|
||||
}
|
||||
}, [orderedTypes, addType]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set<string>();
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev.has(key)) {
|
||||
next.add(key);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
setRenameDrafts((prev) => {
|
||||
const next: Record<string, string> = {};
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev[key] !== undefined) {
|
||||
next[key] = prev[key];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
setRenameErrors((prev) => {
|
||||
const next: Record<string, string> = {};
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev[key] !== undefined) {
|
||||
next[key] = prev[key];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
setTypeErrors((prev) => {
|
||||
const next: Record<string, string> = {};
|
||||
Object.keys(detectors).forEach((key) => {
|
||||
if (prev[key] !== undefined) {
|
||||
next[key] = prev[key];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [detectors]);
|
||||
|
||||
const updateDetectors = useCallback(
|
||||
(nextDetectors: JsonObject, path?: FieldPathList) => {
|
||||
onChange(nextDetectors as unknown, path ?? fieldPathId.path);
|
||||
},
|
||||
[fieldPathId.path, onChange],
|
||||
);
|
||||
|
||||
const getTypeLabel = useCallback(
|
||||
(type: string) =>
|
||||
t(`${sectionPrefix}.${type}.label`, {
|
||||
ns: configNamespace,
|
||||
defaultValue: humanizeKey(type),
|
||||
}),
|
||||
[t, sectionPrefix, configNamespace],
|
||||
);
|
||||
|
||||
const getTypeDescription = useCallback(
|
||||
(type: string) =>
|
||||
t(`${sectionPrefix}.${type}.description`, {
|
||||
ns: configNamespace,
|
||||
defaultValue: "",
|
||||
}),
|
||||
[t, sectionPrefix, configNamespace],
|
||||
);
|
||||
|
||||
const shouldShowRestartForPath = useCallback(
|
||||
(path: Array<string | number>) =>
|
||||
requiresRestartForFieldPath(
|
||||
path,
|
||||
restartRequired,
|
||||
defaultRequiresRestart,
|
||||
),
|
||||
[defaultRequiresRestart, restartRequired],
|
||||
);
|
||||
|
||||
const renderRestartIcon = (isRequired: boolean) => {
|
||||
if (!isRequired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RestartRequiredIndicator className="ml-2" />;
|
||||
};
|
||||
|
||||
const isSingleInstanceType = useCallback(
|
||||
(type: string) => !multiInstanceSet.has(type),
|
||||
[multiInstanceSet],
|
||||
);
|
||||
|
||||
const getDetectorDefaults = useCallback(
|
||||
(type: string) => {
|
||||
const schema = detectorSchemaByType.get(type);
|
||||
if (!schema) {
|
||||
return { type };
|
||||
}
|
||||
|
||||
const base = { type } as Record<string, unknown>;
|
||||
const withDefaults = applySchemaDefaults(schema, base);
|
||||
return { ...withDefaults, type } as Record<string, unknown>;
|
||||
},
|
||||
[detectorSchemaByType],
|
||||
);
|
||||
|
||||
const resolveDuplicateType = useCallback(
|
||||
(targetType: string, excludeKey?: string) => {
|
||||
return Object.entries(detectors).some(([key, value]) => {
|
||||
if (excludeKey && key === excludeKey) {
|
||||
return false;
|
||||
}
|
||||
return getInstanceType(value) === targetType;
|
||||
});
|
||||
},
|
||||
[detectors],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
if (!addType) {
|
||||
setAddError(
|
||||
t("selectItem", {
|
||||
ns: "common",
|
||||
defaultValue: "Select {{item}}",
|
||||
item: t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSingleInstanceType(addType) && resolveDuplicateType(addType)) {
|
||||
setAddError(
|
||||
t("configForm.detectors.singleType", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Only one {{type}} detector is allowed.",
|
||||
type: getTypeLabel(addType),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseKey = addType;
|
||||
let nextKey = baseKey;
|
||||
let index = 2;
|
||||
while (Object.prototype.hasOwnProperty.call(detectors, nextKey)) {
|
||||
nextKey = `${baseKey}${index}`;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const nextDetectors = {
|
||||
...detectors,
|
||||
[nextKey]: getDetectorDefaults(addType),
|
||||
} as JsonObject;
|
||||
|
||||
setAddError(undefined);
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(nextKey);
|
||||
return next;
|
||||
});
|
||||
|
||||
updateDetectors(nextDetectors);
|
||||
}, [
|
||||
addType,
|
||||
t,
|
||||
configNamespace,
|
||||
detectors,
|
||||
getDetectorDefaults,
|
||||
getTypeLabel,
|
||||
isSingleInstanceType,
|
||||
resolveDuplicateType,
|
||||
updateDetectors,
|
||||
]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(key: string) => {
|
||||
const { [key]: _, ...rest } = detectors;
|
||||
updateDetectors(rest as JsonObject);
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[detectors, updateDetectors],
|
||||
);
|
||||
|
||||
const commitRename = useCallback(
|
||||
(key: string, nextKey: string) => {
|
||||
const trimmed = nextKey.trim();
|
||||
if (!trimmed) {
|
||||
setRenameErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: t("configForm.detectors.keyRequired", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Detector name is required.",
|
||||
}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed !== key && detectors[trimmed] !== undefined) {
|
||||
setRenameErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: t("configForm.detectors.keyDuplicate", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Detector name already exists.",
|
||||
}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setRenameErrors((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
setRenameDrafts((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
if (trimmed === key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { [key]: value, ...rest } = detectors;
|
||||
const nextDetectors = { ...rest, [trimmed]: value } as JsonObject;
|
||||
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.delete(key)) {
|
||||
next.add(trimmed);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
updateDetectors(nextDetectors);
|
||||
},
|
||||
[detectors, t, updateDetectors],
|
||||
);
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(key: string, nextType: string) => {
|
||||
const currentType = getInstanceType(detectors[key]);
|
||||
if (!nextType || nextType === currentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isSingleInstanceType(nextType) &&
|
||||
resolveDuplicateType(nextType, key)
|
||||
) {
|
||||
setTypeErrors((prev) => ({
|
||||
...prev,
|
||||
[key]: t("configForm.detectors.singleType", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Only one {{type}} detector is allowed.",
|
||||
type: getTypeLabel(nextType),
|
||||
}),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setTypeErrors((prev) => {
|
||||
const { [key]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
const nextDetectors = {
|
||||
...detectors,
|
||||
[key]: getDetectorDefaults(nextType),
|
||||
} as JsonObject;
|
||||
|
||||
updateDetectors(nextDetectors);
|
||||
},
|
||||
[
|
||||
detectors,
|
||||
getDetectorDefaults,
|
||||
getTypeLabel,
|
||||
isSingleInstanceType,
|
||||
resolveDuplicateType,
|
||||
t,
|
||||
updateDetectors,
|
||||
],
|
||||
);
|
||||
|
||||
const getInstanceUiSchema = useCallback(
|
||||
(type: string) => {
|
||||
const baseUiSchema =
|
||||
(uiSchema?.additionalProperties as UiSchema | undefined) ?? {};
|
||||
const globalHidden = buildHiddenUiSchema(globalHiddenFields);
|
||||
const hiddenOverrides = buildHiddenUiSchema(hiddenByType[type] ?? []);
|
||||
const typeHidden = { type: { "ui:widget": "hidden" } } as UiSchema;
|
||||
const nestedOverrides = {
|
||||
"ui:options": {
|
||||
disableNestedCard: true,
|
||||
},
|
||||
} as UiSchema;
|
||||
|
||||
const withGlobalHidden = mergeUiSchema(baseUiSchema, globalHidden);
|
||||
const withTypeHidden = mergeUiSchema(withGlobalHidden, hiddenOverrides);
|
||||
const withTypeHiddenAndOptions = mergeUiSchema(
|
||||
withTypeHidden,
|
||||
typeHidden,
|
||||
);
|
||||
return mergeUiSchema(withTypeHiddenAndOptions, nestedOverrides);
|
||||
},
|
||||
[globalHiddenFields, hiddenByType, uiSchema?.additionalProperties],
|
||||
);
|
||||
|
||||
const renderInstanceForm = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
const SchemaField = registry.fields.SchemaField;
|
||||
const type = getInstanceType(value);
|
||||
const schema = type ? detectorSchemaByType.get(type) : undefined;
|
||||
|
||||
if (!SchemaField || !schema || !type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const instanceUiSchema = getInstanceUiSchema(type);
|
||||
const instanceFieldPathId = toFieldPathId(
|
||||
key,
|
||||
registry.globalFormOptions,
|
||||
fieldPathId.path,
|
||||
);
|
||||
|
||||
const instanceErrorSchema = (
|
||||
errorSchema as Record<string, ErrorSchema> | undefined
|
||||
)?.[key];
|
||||
|
||||
const handleInstanceChange = (
|
||||
nextValue: unknown,
|
||||
path: FieldPathList,
|
||||
errors?: ErrorSchema,
|
||||
id?: string,
|
||||
) => {
|
||||
onChange(nextValue, path, errors, id);
|
||||
};
|
||||
|
||||
return (
|
||||
<SchemaField
|
||||
name={key}
|
||||
schema={schema}
|
||||
uiSchema={instanceUiSchema}
|
||||
fieldPathId={instanceFieldPathId}
|
||||
formData={value}
|
||||
errorSchema={instanceErrorSchema}
|
||||
onChange={handleInstanceChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
registry={registry}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
hideError={hideError}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
detectorSchemaByType,
|
||||
getInstanceUiSchema,
|
||||
disabled,
|
||||
errorSchema,
|
||||
fieldPathId,
|
||||
hideError,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
readonly,
|
||||
registry,
|
||||
],
|
||||
);
|
||||
|
||||
if (!availableTypes.length) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("configForm.detectors.noSchema", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "No detector schemas are available.",
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const detectorEntries = Object.entries(detectors);
|
||||
const isDisabled = Boolean(disabled || readonly);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{detectorEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("configForm.detectors.none", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "No detector instances configured.",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{detectorEntries.map(([key, value]) => {
|
||||
const type = getInstanceType(value) ?? "";
|
||||
const typeLabel = type ? getTypeLabel(type) : key;
|
||||
const typeDescription = type ? getTypeDescription(type) : "";
|
||||
const isOpen = openKeys.has(key);
|
||||
const renameDraft = renameDrafts[key] ?? key;
|
||||
const detectorPath = [...fieldPathId.path, key];
|
||||
const detectorTypePath = [...detectorPath, "type"];
|
||||
const detectorTypeRequiresRestart =
|
||||
shouldShowRestartForPath(detectorTypePath);
|
||||
|
||||
return (
|
||||
<div key={key} className="rounded-lg border bg-card">
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="mt-0.5"
|
||||
>
|
||||
{isOpen ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<div>
|
||||
<div className="flex items-center text-sm font-medium">
|
||||
{typeLabel}
|
||||
{renderRestartIcon(detectorTypeRequiresRestart)}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
{typeDescription && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{typeDescription}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => handleRemove(key)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-4 border-t p-4">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center">
|
||||
{t("label.ID", {
|
||||
ns: "common",
|
||||
defaultValue: "ID",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
value={renameDraft}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => {
|
||||
setRenameDrafts((prev) => ({
|
||||
...prev,
|
||||
[key]: event.target.value,
|
||||
}));
|
||||
}}
|
||||
onBlur={(event) =>
|
||||
commitRename(key, event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitRename(key, renameDraft);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("field.internalID", {
|
||||
ns: "common",
|
||||
defaultValue:
|
||||
"The Internal ID Frigate uses in the configuration and database",
|
||||
})}
|
||||
</p>
|
||||
{renameErrors[key] && (
|
||||
<p className="text-xs text-danger">
|
||||
{renameErrors[key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3 space-y-2">
|
||||
<Label className="flex items-center">
|
||||
{t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value) =>
|
||||
handleTypeChange(key, value)
|
||||
}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("selectItem", {
|
||||
ns: "common",
|
||||
defaultValue: "Select {{item}}",
|
||||
item: t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderedTypes.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{getTypeLabel(option)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{typeErrors[key] && (
|
||||
<p className="text-xs text-danger">
|
||||
{typeErrors[key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(readonly && "opacity-90")}>
|
||||
{renderInstanceForm(key, value)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-start pt-5">
|
||||
<div className="w-full max-w-lg rounded-lg border bg-card p-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{t("configForm.detectors.add", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Add detector",
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>
|
||||
{t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={addType ?? ""}
|
||||
onValueChange={(value) => {
|
||||
setAddError(undefined);
|
||||
setAddType(value);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("selectItem", {
|
||||
ns: "common",
|
||||
defaultValue: "Select {{item}}",
|
||||
item: t("detectors.type.label", {
|
||||
ns: configNamespace,
|
||||
defaultValue: "Type",
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderedTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getTypeLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{addError && <p className="text-xs text-danger">{addError}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAdd}
|
||||
disabled={isDisabled}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", {
|
||||
ns: "common",
|
||||
defaultValue: "Add",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* LayoutGridField - RJSF field for responsive, semantic grid layouts
|
||||
*
|
||||
* Overview:
|
||||
* - Apply a responsive grid to object properties using `ui:layoutGrid` while
|
||||
* preserving the default `ObjectFieldTemplate` behavior (cards, nested
|
||||
* collapsibles, add button, and i18n).
|
||||
* - Falls back to the original template when `ui:layoutGrid` is not present.
|
||||
*
|
||||
* Capabilities:
|
||||
* - 12-column grid logic. `ui:col` accepts a number (1-12) or a Tailwind class
|
||||
* string (e.g. "col-span-12 md:col-span-4") for responsive column widths.
|
||||
* - Per-row and global class overrides:
|
||||
* - `ui:options.layoutGrid.rowClassName` (default: "grid-cols-12") is merged
|
||||
* with the base `grid gap-4` classes.
|
||||
* - `ui:options.layoutGrid.advancedRowClassName` (default: "grid-cols-12")
|
||||
* controls advanced-section rows.
|
||||
* - Per-row `ui:className` and per-column `ui:className`/`className` are
|
||||
* supported for fine-grained layout control.
|
||||
* - Optional `useGridForAdvanced` (via `ui:options.layoutGrid`) to toggle
|
||||
* whether advanced fields use the grid or fall back to stacked layout.
|
||||
* - Integrates with `ui:groups` to show semantic group labels (resolved via
|
||||
* `config/groups` i18n). If a layout row contains fields from the same group,
|
||||
* that row shows the group label above it; leftover or ungrouped fields are
|
||||
* rendered after the configured rows.
|
||||
* - Hidden fields (`ui:widget: "hidden"`) are ignored.
|
||||
*
|
||||
* Internationalization
|
||||
* - Advanced collapsible labels use `label.advancedSettingsCount` and
|
||||
* `label.advancedCount` in the `common` namespace.
|
||||
* - Group labels are looked up in `config/groups` (uses `sectionI18nPrefix`
|
||||
* when available).
|
||||
*
|
||||
* Usage examples:
|
||||
* Basic:
|
||||
* {
|
||||
* "ui:field": "LayoutGridField",
|
||||
* "ui:layoutGrid": [
|
||||
* { "ui:row": ["field1", "field2"] },
|
||||
* { "ui:row": ["field3"] }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Custom columns and responsive classes:
|
||||
* {
|
||||
* "ui:field": "LayoutGridField",
|
||||
* "ui:options": {
|
||||
* "layoutGrid": { "rowClassName": "grid-cols-12 md:grid-cols-6", "useGridForAdvanced": true }
|
||||
* },
|
||||
* "ui:layoutGrid": [
|
||||
* {
|
||||
* "ui:row": [
|
||||
* { "field1": { "ui:col": "col-span-12 md:col-span-4", "ui:className": "md:col-start-2" } },
|
||||
* { "field2": { "ui:col": 4 } }
|
||||
* ],
|
||||
* "ui:className": "gap-6"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Groups and rows:
|
||||
* {
|
||||
* "ui:field": "LayoutGridField",
|
||||
* "ui:groups": { "resolution": ["fps","width","height"], "tracking": ["min_initialized","max_disappeared"] },
|
||||
* "ui:layoutGrid": [
|
||||
* { "ui:row": ["enabled"] },
|
||||
* { "ui:row": ["fps","width","height"] }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* Notes:
|
||||
* - `ui:layoutGrid` must be an array; non-array values are ignored and the
|
||||
* default ObjectFieldTemplate is used instead.
|
||||
* - This implementation adheres to RJSF patterns (use `ui:options`,
|
||||
* `ui:className`, and `ui:layoutGrid` as documented) while adding a few
|
||||
* Frigate-specific conveniences (defaults and Tailwind-friendly class
|
||||
* handling).
|
||||
*/
|
||||
|
||||
import type { FieldProps, ObjectFieldTemplateProps } from "@rjsf/utils";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
import {
|
||||
getDomainFromNamespace,
|
||||
hasOverrideAtPath,
|
||||
humanizeKey,
|
||||
} from "../utils";
|
||||
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
||||
|
||||
type LayoutGridColumnConfig = {
|
||||
"ui:col"?: number | string;
|
||||
"ui:className"?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type LayoutRow = {
|
||||
"ui:row": Array<string | Record<string, LayoutGridColumnConfig>>;
|
||||
"ui:className"?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type LayoutGrid = LayoutRow[];
|
||||
|
||||
type LayoutGridOptions = {
|
||||
rowClassName?: string;
|
||||
advancedRowClassName?: string;
|
||||
useGridForAdvanced?: boolean;
|
||||
};
|
||||
|
||||
interface PropertyElement {
|
||||
name: string;
|
||||
content: React.ReactElement;
|
||||
}
|
||||
|
||||
function isObjectLikeElement(item: PropertyElement) {
|
||||
const fieldSchema = item.content.props?.schema as
|
||||
| { type?: string | string[] }
|
||||
| undefined;
|
||||
return fieldSchema?.type === "object";
|
||||
}
|
||||
|
||||
// Custom ObjectFieldTemplate wrapper that applies grid layout
|
||||
function GridLayoutObjectFieldTemplate(
|
||||
props: ObjectFieldTemplateProps,
|
||||
originalObjectFieldTemplate: React.ComponentType<ObjectFieldTemplateProps>,
|
||||
) {
|
||||
const {
|
||||
uiSchema,
|
||||
properties,
|
||||
registry,
|
||||
schema,
|
||||
onAddProperty,
|
||||
formData,
|
||||
disabled,
|
||||
readonly,
|
||||
} = props;
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const { t } = useTranslation(["common", "config/groups"]);
|
||||
|
||||
// Use the original ObjectFieldTemplate passed as parameter, not from registry
|
||||
const ObjectFieldTemplate = originalObjectFieldTemplate;
|
||||
|
||||
// Get layout configuration
|
||||
const layoutGrid = Array.isArray(uiSchema?.["ui:layoutGrid"])
|
||||
? (uiSchema?.["ui:layoutGrid"] as LayoutGrid)
|
||||
: [];
|
||||
const layoutGridOptions =
|
||||
(uiSchema?.["ui:options"] as { layoutGrid?: LayoutGridOptions } | undefined)
|
||||
?.layoutGrid ?? {};
|
||||
const baseRowClassName = layoutGridOptions.rowClassName ?? "grid-cols-12";
|
||||
const advancedRowClassName =
|
||||
layoutGridOptions.advancedRowClassName ?? "grid-cols-12";
|
||||
const useGridForAdvanced = layoutGridOptions.useGridForAdvanced ?? true;
|
||||
const groupDefinitions =
|
||||
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
|
||||
const overrides = formContext?.overrides;
|
||||
const fieldPath = props.fieldPathId.path;
|
||||
|
||||
const isPathModified = (path: Array<string | number>) =>
|
||||
hasOverrideAtPath(overrides, path, formContext?.formData);
|
||||
|
||||
// Override the properties rendering with grid layout
|
||||
const isHiddenProp = (prop: (typeof properties)[number]) =>
|
||||
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
|
||||
|
||||
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
|
||||
|
||||
// Separate regular and advanced properties
|
||||
const advancedProps = visibleProps.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
|
||||
);
|
||||
const regularProps = visibleProps.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
||||
);
|
||||
const hasModifiedAdvanced = advancedProps.some((prop) =>
|
||||
isPathModified([...fieldPath, prop.name]),
|
||||
);
|
||||
const [showAdvanced, setShowAdvanced] = useState(hasModifiedAdvanced);
|
||||
|
||||
// If no layout grid is defined, use the default template
|
||||
if (layoutGrid.length === 0) {
|
||||
return <ObjectFieldTemplate {...props} />;
|
||||
}
|
||||
|
||||
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
|
||||
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
|
||||
|
||||
const getGroupLabel = (groupKey: string) => {
|
||||
if (domain && sectionI18nPrefix) {
|
||||
return t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
|
||||
ns: "config/groups",
|
||||
defaultValue: humanizeKey(groupKey),
|
||||
});
|
||||
}
|
||||
|
||||
return t(`groups.${groupKey}`, {
|
||||
ns: "config/groups",
|
||||
defaultValue: humanizeKey(groupKey),
|
||||
});
|
||||
};
|
||||
|
||||
// Render fields using the layout grid structure
|
||||
const renderGridLayout = (items: PropertyElement[], rowClassName: string) => {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
const itemMap = new Map(items.map((item) => [item.name, item]));
|
||||
const renderedFields = new Set<string>();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{layoutGrid.map((rowDef, rowIndex) => {
|
||||
const rowItems = rowDef["ui:row"];
|
||||
const cols: React.ReactNode[] = [];
|
||||
|
||||
rowItems.forEach((colDef, colIndex) => {
|
||||
let fieldName: string;
|
||||
let colSpan: number | string = 12; // Default to full width
|
||||
let colClassName: string | undefined;
|
||||
|
||||
if (typeof colDef === "string") {
|
||||
fieldName = colDef;
|
||||
} else {
|
||||
// Object with field name as key and ui:col as value
|
||||
const entries = Object.entries(colDef);
|
||||
if (entries.length === 0) return;
|
||||
const [name, config] = entries[0];
|
||||
fieldName = name;
|
||||
colSpan = config["ui:col"] ?? 12;
|
||||
colClassName = config["ui:className"] ?? config.className;
|
||||
}
|
||||
|
||||
const item = itemMap.get(fieldName);
|
||||
if (!item) return;
|
||||
|
||||
renderedFields.add(fieldName);
|
||||
|
||||
// Calculate column width class (using 12-column grid)
|
||||
const colSpanClass =
|
||||
typeof colSpan === "string" ? colSpan : `col-span-${colSpan}`;
|
||||
const colClass = cn(colSpanClass, colClassName);
|
||||
|
||||
cols.push(
|
||||
<div key={`${rowIndex}-${colIndex}`} className={colClass}>
|
||||
{item.content}
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
if (cols.length === 0) return null;
|
||||
|
||||
const rowClass = cn(
|
||||
"grid gap-4",
|
||||
rowClassName,
|
||||
rowDef["ui:className"],
|
||||
rowDef.className,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={rowIndex} className={rowClass}>
|
||||
{cols}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{Array.from(itemMap.keys())
|
||||
.filter((name) => !renderedFields.has(name))
|
||||
.map((name) => {
|
||||
const item = itemMap.get(name);
|
||||
return item ? (
|
||||
<div key={name} className="space-y-6">
|
||||
{item.content}
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGroupedGridLayout = (
|
||||
items: PropertyElement[],
|
||||
rowClassName: string,
|
||||
) => {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Object.keys(groupDefinitions).length === 0) {
|
||||
return renderGridLayout(items, rowClassName);
|
||||
}
|
||||
|
||||
const itemMap = new Map(items.map((item) => [item.name, item]));
|
||||
const renderedFields = new Set<string>();
|
||||
const renderedGroups = new Set<string>();
|
||||
const groupMap = new Map<string, string>();
|
||||
|
||||
Object.entries(groupDefinitions).forEach(([groupKey, fields]) => {
|
||||
fields.forEach((field) => {
|
||||
groupMap.set(field, groupKey);
|
||||
});
|
||||
});
|
||||
|
||||
const rows = layoutGrid
|
||||
.map((rowDef, rowIndex) => {
|
||||
const rowItems = rowDef["ui:row"];
|
||||
const cols: React.ReactNode[] = [];
|
||||
const rowFieldNames: string[] = [];
|
||||
|
||||
rowItems.forEach((colDef, colIndex) => {
|
||||
let fieldName: string;
|
||||
let colSpan: number | string = 12;
|
||||
let colClassName: string | undefined;
|
||||
|
||||
if (typeof colDef === "string") {
|
||||
fieldName = colDef;
|
||||
} else {
|
||||
const entries = Object.entries(colDef);
|
||||
if (entries.length === 0) return;
|
||||
const [name, config] = entries[0];
|
||||
fieldName = name;
|
||||
colSpan = config["ui:col"] ?? 12;
|
||||
colClassName = config["ui:className"] ?? config.className;
|
||||
}
|
||||
|
||||
const item = itemMap.get(fieldName);
|
||||
if (!item) return;
|
||||
|
||||
renderedFields.add(fieldName);
|
||||
rowFieldNames.push(fieldName);
|
||||
|
||||
const colSpanClass =
|
||||
typeof colSpan === "string" ? colSpan : `col-span-${colSpan}`;
|
||||
const colClass = cn(colSpanClass, colClassName);
|
||||
|
||||
cols.push(
|
||||
<div key={`${rowIndex}-${colIndex}`} className={colClass}>
|
||||
{item.content}
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
if (cols.length === 0) return null;
|
||||
|
||||
const rowClass = cn(
|
||||
"grid gap-4",
|
||||
rowClassName,
|
||||
rowDef["ui:className"],
|
||||
rowDef.className,
|
||||
);
|
||||
|
||||
const rowGroupKeys = rowFieldNames
|
||||
.map((name) => groupMap.get(name))
|
||||
.filter(Boolean) as string[];
|
||||
const rowGroupKey =
|
||||
rowGroupKeys.length > 0 &&
|
||||
rowGroupKeys.length === rowFieldNames.length &&
|
||||
new Set(rowGroupKeys).size === 1
|
||||
? rowGroupKeys[0]
|
||||
: undefined;
|
||||
const showGroupLabel = rowGroupKey && !renderedGroups.has(rowGroupKey);
|
||||
|
||||
if (showGroupLabel) {
|
||||
renderedGroups.add(rowGroupKey);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className={cn(
|
||||
"space-y-4",
|
||||
rowGroupKey &&
|
||||
"rounded-lg border border-border/70 bg-card/30 p-4",
|
||||
)}
|
||||
>
|
||||
{showGroupLabel && (
|
||||
<div className="border-b border-border/60 pb-2 text-sm font-semibold text-primary-variant">
|
||||
{getGroupLabel(rowGroupKey)}
|
||||
</div>
|
||||
)}
|
||||
<div className={rowClass}>{cols}</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const remainingItems = Array.from(itemMap.keys())
|
||||
.filter((name) => !renderedFields.has(name))
|
||||
.map((name) => itemMap.get(name))
|
||||
.filter(Boolean) as PropertyElement[];
|
||||
|
||||
const groupedLeftovers = new Map<string, PropertyElement[]>();
|
||||
const ungroupedLeftovers: PropertyElement[] = [];
|
||||
|
||||
remainingItems.forEach((item) => {
|
||||
const groupKey = groupMap.get(item.name);
|
||||
if (groupKey) {
|
||||
const existing = groupedLeftovers.get(groupKey);
|
||||
if (existing) {
|
||||
existing.push(item);
|
||||
} else {
|
||||
groupedLeftovers.set(groupKey, [item]);
|
||||
}
|
||||
} else {
|
||||
ungroupedLeftovers.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const leftoverSections: React.ReactNode[] = [];
|
||||
|
||||
groupedLeftovers.forEach((groupItems, groupKey) => {
|
||||
const showGroupLabel = !renderedGroups.has(groupKey);
|
||||
if (showGroupLabel) {
|
||||
renderedGroups.add(groupKey);
|
||||
}
|
||||
|
||||
leftoverSections.push(
|
||||
<div
|
||||
key={groupKey}
|
||||
className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4"
|
||||
>
|
||||
{showGroupLabel && (
|
||||
<div className="border-b border-border/60 pb-2 text-sm font-semibold text-primary-variant">
|
||||
{getGroupLabel(groupKey)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
{groupItems.map((item) => (
|
||||
<div key={item.name}>{item.content}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
if (ungroupedLeftovers.length > 0) {
|
||||
leftoverSections.push(
|
||||
<div
|
||||
key="ungrouped-leftovers"
|
||||
className={cn(
|
||||
"space-y-6",
|
||||
(rows.length > 0 || groupedLeftovers.size > 0) && "pt-2",
|
||||
)}
|
||||
>
|
||||
{ungroupedLeftovers.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className={cn(
|
||||
groupedLeftovers.size > 0 &&
|
||||
!isObjectLikeElement(item) &&
|
||||
"px-4",
|
||||
)}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{rows}
|
||||
{leftoverSections}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStackedLayout = (items: PropertyElement[]) => {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => (
|
||||
<div key={item.name}>{item.content}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const regularLayout = renderGroupedGridLayout(regularProps, baseRowClassName);
|
||||
const advancedLayout = useGridForAdvanced
|
||||
? renderGroupedGridLayout(advancedProps, advancedRowClassName)
|
||||
: renderStackedLayout(advancedProps);
|
||||
|
||||
// Create modified props with custom property rendering
|
||||
// Render using the original template but with our custom content
|
||||
const isRoot = registry?.rootSchema === props.schema;
|
||||
|
||||
if (isRoot) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{regularLayout}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
isRoot
|
||||
>
|
||||
{advancedLayout}
|
||||
</AdvancedCollapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// We need to inject our custom rendering into the template
|
||||
// Since we can't directly modify the template's internal rendering,
|
||||
// we'll render the full structure ourselves
|
||||
return (
|
||||
<ObjectFieldTemplate {...props}>
|
||||
<div className="space-y-4">
|
||||
{regularLayout}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
>
|
||||
{advancedLayout}
|
||||
</AdvancedCollapsible>
|
||||
</div>
|
||||
</ObjectFieldTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutGridField(props: FieldProps) {
|
||||
const { registry, schema, uiSchema, idSchema, formData } = props;
|
||||
|
||||
// Store the original ObjectFieldTemplate before any modifications
|
||||
const originalObjectFieldTemplate = registry.templates.ObjectFieldTemplate;
|
||||
|
||||
// Get the ObjectField component from the registry
|
||||
const ObjectField = registry.fields.ObjectField;
|
||||
|
||||
// Create a modified registry with our custom template
|
||||
// But we'll pass the original template to it to prevent circular reference
|
||||
const gridObjectFieldTemplate = useCallback(
|
||||
(tProps: ObjectFieldTemplateProps) =>
|
||||
GridLayoutObjectFieldTemplate(tProps, originalObjectFieldTemplate),
|
||||
[originalObjectFieldTemplate],
|
||||
);
|
||||
|
||||
const modifiedRegistry = useMemo(
|
||||
() => ({
|
||||
...registry,
|
||||
templates: {
|
||||
...registry.templates,
|
||||
ObjectFieldTemplate: gridObjectFieldTemplate,
|
||||
},
|
||||
}),
|
||||
[registry, gridObjectFieldTemplate],
|
||||
);
|
||||
|
||||
// Delegate to ObjectField with the modified registry
|
||||
return (
|
||||
<ObjectField
|
||||
{...props}
|
||||
registry={modifiedRegistry}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
idSchema={idSchema}
|
||||
formData={formData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<string, RJSFSchema> })
|
||||
?.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<ReplaceRule>) => {
|
||||
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 (
|
||||
<Card className="w-full">
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle
|
||||
className={cn("text-sm", isModified && "text-danger")}
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{open ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-4 p-4 pt-0">
|
||||
{rules.length > 0 && (
|
||||
<div className="grid grid-cols-12 gap-2">
|
||||
<div className="col-span-12 space-y-2 md:col-span-6">
|
||||
{patternTitle && (
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{patternTitle}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-12 space-y-2 md:col-span-5">
|
||||
{replacementTitle && (
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{replacementTitle}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule, index) => {
|
||||
const patternId = `${baseId}-${index}-pattern`;
|
||||
const replacementId = `${baseId}-${index}-replacement`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-12 items-start gap-2"
|
||||
>
|
||||
<div className="col-span-12 md:col-span-6">
|
||||
<Input
|
||||
id={patternId}
|
||||
value={rule?.pattern ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) =>
|
||||
handleUpdate(index, { pattern: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-12 md:col-span-5">
|
||||
<Input
|
||||
id={replacementId}
|
||||
value={rule?.replacement ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(e) =>
|
||||
handleUpdate(index, { replacement: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-12 flex items-center md:col-span-1 md:justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemove(index)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", { ns: "common", defaultValue: "Add" })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReplaceRulesField;
|
||||
@@ -0,0 +1,4 @@
|
||||
// Custom RJSF Fields
|
||||
export { LayoutGridField } from "./LayoutGridField";
|
||||
export { DetectorHardwareField } from "./DetectorHardwareField";
|
||||
export { ReplaceRulesField } from "./ReplaceRulesField";
|
||||
@@ -0,0 +1,60 @@
|
||||
// Utilities for handling anyOf with null patterns
|
||||
import type { StrictRJSFSchema } from "@rjsf/utils";
|
||||
|
||||
/**
|
||||
* Checks if a schema is anyOf/oneOf with exactly [Type, null].
|
||||
* This indicates a nullable field in Pydantic schemas.
|
||||
*/
|
||||
export function isNullableUnionSchema(schema: StrictRJSFSchema): boolean {
|
||||
const union = schema.anyOf ?? schema.oneOf;
|
||||
if (!union || !Array.isArray(union) || union.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hasNull = false;
|
||||
let nonNullCount = 0;
|
||||
|
||||
for (const item of union) {
|
||||
if (typeof item !== "object" || item === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const itemSchema = item as StrictRJSFSchema;
|
||||
|
||||
if (itemSchema.type === "null") {
|
||||
hasNull = true;
|
||||
} else {
|
||||
nonNullCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return hasNull && nonNullCount === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backwards-compatible alias for nullable fields
|
||||
*/
|
||||
export function isSimpleNullableField(schema: StrictRJSFSchema): boolean {
|
||||
return isNullableUnionSchema(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the non-null schema from an anyOf containing [Type, null]
|
||||
*/
|
||||
export function getNonNullSchema(
|
||||
schema: StrictRJSFSchema,
|
||||
): StrictRJSFSchema | null {
|
||||
const union = schema.anyOf ?? schema.oneOf;
|
||||
if (!union || !Array.isArray(union)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
(union.find(
|
||||
(item) =>
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
(item as StrictRJSFSchema).type !== "null",
|
||||
) as StrictRJSFSchema) || null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Custom RJSF Theme for Frigate
|
||||
// Maps RJSF templates and widgets to shadcn/ui components
|
||||
|
||||
import type {
|
||||
WidgetProps,
|
||||
FieldTemplateProps,
|
||||
RegistryWidgetsType,
|
||||
RegistryFieldsType,
|
||||
TemplatesType,
|
||||
} from "@rjsf/utils";
|
||||
|
||||
import { SwitchWidget } from "./widgets/SwitchWidget";
|
||||
import { SelectWidget } from "./widgets/SelectWidget";
|
||||
import { TextWidget } from "./widgets/TextWidget";
|
||||
import { PasswordWidget } from "./widgets/PasswordWidget";
|
||||
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 { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget";
|
||||
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
|
||||
import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
|
||||
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
|
||||
import { InputRolesWidget } from "./widgets/InputRolesWidget";
|
||||
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||
|
||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||
import { ArrayFieldTemplate } from "./templates/ArrayFieldTemplate";
|
||||
import { ArrayFieldItemTemplate } from "./templates/ArrayFieldItemTemplate";
|
||||
import { BaseInputTemplate } from "./templates/BaseInputTemplate";
|
||||
import { DescriptionFieldTemplate } from "./templates/DescriptionFieldTemplate";
|
||||
import { TitleFieldTemplate } from "./templates/TitleFieldTemplate";
|
||||
import { ErrorListTemplate } from "./templates/ErrorListTemplate";
|
||||
import { MultiSchemaFieldTemplate } from "./templates/MultiSchemaFieldTemplate";
|
||||
import { WrapIfAdditionalTemplate } from "./templates/WrapIfAdditionalTemplate";
|
||||
|
||||
import { LayoutGridField } from "./fields/LayoutGridField";
|
||||
import { DetectorHardwareField } from "./fields/DetectorHardwareField";
|
||||
import { ReplaceRulesField } from "./fields/ReplaceRulesField";
|
||||
import { CameraInputsField } from "./fields/CameraInputsField";
|
||||
|
||||
export interface FrigateTheme {
|
||||
widgets: RegistryWidgetsType;
|
||||
templates: Partial<TemplatesType>;
|
||||
fields: RegistryFieldsType;
|
||||
}
|
||||
|
||||
export const frigateTheme: FrigateTheme = {
|
||||
widgets: {
|
||||
// Override default widgets with shadcn/ui styled versions
|
||||
TextWidget: TextWidget,
|
||||
PasswordWidget: PasswordWidget,
|
||||
SelectWidget: SelectWidget,
|
||||
CheckboxWidget: SwitchWidget,
|
||||
ArrayAsTextWidget: ArrayAsTextWidget,
|
||||
FfmpegArgsWidget: FfmpegArgsWidget,
|
||||
CameraPathWidget: CameraPathWidget,
|
||||
inputRoles: InputRolesWidget,
|
||||
// Custom widgets
|
||||
switch: SwitchWidget,
|
||||
password: PasswordWidget,
|
||||
select: SelectWidget,
|
||||
range: RangeWidget,
|
||||
tags: TagsWidget,
|
||||
color: ColorWidget,
|
||||
textarea: TextareaWidget,
|
||||
switches: SwitchesWidget,
|
||||
objectLabels: ObjectLabelSwitchesWidget,
|
||||
audioLabels: AudioLabelSwitchesWidget,
|
||||
zoneNames: ZoneSwitchesWidget,
|
||||
timezoneSelect: TimezoneSelectWidget,
|
||||
},
|
||||
templates: {
|
||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
||||
ObjectFieldTemplate: ObjectFieldTemplate,
|
||||
ArrayFieldTemplate: ArrayFieldTemplate,
|
||||
ArrayFieldItemTemplate: ArrayFieldItemTemplate,
|
||||
BaseInputTemplate: BaseInputTemplate as React.ComponentType<WidgetProps>,
|
||||
DescriptionFieldTemplate: DescriptionFieldTemplate,
|
||||
TitleFieldTemplate: TitleFieldTemplate,
|
||||
ErrorListTemplate: ErrorListTemplate,
|
||||
MultiSchemaFieldTemplate: MultiSchemaFieldTemplate,
|
||||
WrapIfAdditionalTemplate: WrapIfAdditionalTemplate,
|
||||
},
|
||||
fields: {
|
||||
LayoutGridField: LayoutGridField,
|
||||
DetectorHardwareField: DetectorHardwareField,
|
||||
ReplaceRulesField: ReplaceRulesField,
|
||||
CameraInputsField: CameraInputsField,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
// RJSF Custom Theme
|
||||
// Maps RJSF components to existing shadcn/ui components
|
||||
|
||||
export { frigateTheme } from "./frigateTheme";
|
||||
export type { FrigateTheme } from "./frigateTheme";
|
||||
@@ -0,0 +1,58 @@
|
||||
import type {
|
||||
ArrayFieldItemTemplateProps,
|
||||
FormContextType,
|
||||
RJSFSchema,
|
||||
StrictRJSFSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { getTemplate, getUiOptions } from "@rjsf/utils";
|
||||
|
||||
/**
|
||||
* Custom ArrayFieldItemTemplate to ensure array item content uses full width
|
||||
* while keeping action buttons aligned to the right.
|
||||
*/
|
||||
export function ArrayFieldItemTemplate<
|
||||
T = unknown,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = FormContextType,
|
||||
>(props: ArrayFieldItemTemplateProps<T, S, F>) {
|
||||
const {
|
||||
children,
|
||||
buttonsProps,
|
||||
displayLabel,
|
||||
hasDescription,
|
||||
hasToolbar,
|
||||
uiSchema,
|
||||
registry,
|
||||
} = props;
|
||||
|
||||
const uiOptions = getUiOptions<T, S, F>(uiSchema);
|
||||
const ArrayFieldItemButtonsTemplate = getTemplate<
|
||||
"ArrayFieldItemButtonsTemplate",
|
||||
T,
|
||||
S,
|
||||
F
|
||||
>("ArrayFieldItemButtonsTemplate", registry, uiOptions);
|
||||
|
||||
const margin = hasDescription ? -6 : 22;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-2 flex w-full flex-row items-start gap-2">
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
{hasToolbar && (
|
||||
<div
|
||||
className="flex shrink-0 items-start justify-end gap-2"
|
||||
style={{
|
||||
marginLeft: "5px",
|
||||
marginTop: displayLabel ? `${margin}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<ArrayFieldItemButtonsTemplate {...buttonsProps} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArrayFieldItemTemplate;
|
||||
@@ -0,0 +1,60 @@
|
||||
// Array Field Template - renders array fields with add/remove controls
|
||||
import type { ArrayFieldTemplateProps } from "@rjsf/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuPlus } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
|
||||
const { items, canAdd, onAddClick, disabled, readonly, schema } = props;
|
||||
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
// Simple items (strings, numbers) render inline
|
||||
const isSimpleType =
|
||||
schema.items &&
|
||||
typeof schema.items === "object" &&
|
||||
"type" in schema.items &&
|
||||
["string", "number", "integer", "boolean"].includes(
|
||||
schema.items.type as string,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && !canAdd && (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
{t("no_items", { ns: "common", defaultValue: "No items" })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((element, index) => {
|
||||
// RJSF items are pre-rendered React elements, render them directly
|
||||
return (
|
||||
<div
|
||||
key={element.key || index}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2",
|
||||
!isSimpleType && "flex-col",
|
||||
)}
|
||||
>
|
||||
{element}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{canAdd && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAddClick}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("button.add", { ns: "common", defaultValue: "Add" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Base Input Template - default input wrapper
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
export function BaseInputTemplate(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
type,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
schema,
|
||||
options,
|
||||
} = props;
|
||||
|
||||
const inputType = type || "text";
|
||||
const fieldClassName = getSizedFieldClassName(options, "xs");
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
if (inputType === "number") {
|
||||
const num = parseFloat(val);
|
||||
onChange(val === "" ? undefined : isNaN(num) ? undefined : num);
|
||||
} else {
|
||||
onChange(val === "" ? undefined : val);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type={inputType}
|
||||
className={fieldClassName}
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || ""}
|
||||
onChange={handleChange}
|
||||
onBlur={(e) => onBlur(id, e.target.value)}
|
||||
onFocus={(e) => onFocus(id, e.target.value)}
|
||||
aria-label={schema.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Description Field Template
|
||||
import type { DescriptionFieldProps } from "@rjsf/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
|
||||
const { description, id } = props;
|
||||
const formContext = (
|
||||
props as { registry?: { formContext?: ConfigFormContext } }
|
||||
).registry?.formContext;
|
||||
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
|
||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : "config/global";
|
||||
|
||||
const { t, i18n } = useTranslation([effectiveNamespace, "common"]);
|
||||
|
||||
let resolvedDescription = description;
|
||||
|
||||
// Support nested keys for both camera-level and consolidated global namespace
|
||||
if (sectionI18nPrefix && effectiveNamespace) {
|
||||
const descriptionKey = `${sectionI18nPrefix}.description`;
|
||||
if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) {
|
||||
resolvedDescription = t(descriptionKey, { ns: effectiveNamespace });
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedDescription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span id={id} className="text-sm text-muted-foreground">
|
||||
{resolvedDescription}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Error List Template - displays form-level errors
|
||||
import type {
|
||||
ErrorListProps,
|
||||
RJSFSchema,
|
||||
RJSFValidationError,
|
||||
} from "@rjsf/utils";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { LuCircleAlert } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { buildTranslationPath, humanizeKey } from "../utils";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
type ErrorSchemaNode = RJSFSchema & {
|
||||
properties?: Record<string, RJSFSchema>;
|
||||
items?: RJSFSchema | RJSFSchema[];
|
||||
additionalProperties?: boolean | RJSFSchema;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const parsePropertyPath = (property: string): Array<string | number> => {
|
||||
const normalizedProperty = property.replace(/^\./, "").trim();
|
||||
if (!normalizedProperty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return normalizedProperty
|
||||
.split(".")
|
||||
.filter(Boolean)
|
||||
.map((segment) => {
|
||||
const maybeIndex = Number(segment);
|
||||
return Number.isInteger(maybeIndex) ? maybeIndex : segment;
|
||||
});
|
||||
};
|
||||
|
||||
const resolveSchemaNodeForPath = (
|
||||
schema: RJSFSchema | undefined,
|
||||
segments: Array<string | number>,
|
||||
): ErrorSchemaNode | undefined => {
|
||||
if (!schema) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let currentSchema: ErrorSchemaNode | undefined = schema as ErrorSchemaNode;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (!currentSchema) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof segment === "number") {
|
||||
const items = currentSchema.items;
|
||||
if (Array.isArray(items)) {
|
||||
currentSchema = items[0] as ErrorSchemaNode | undefined;
|
||||
} else {
|
||||
currentSchema = items as ErrorSchemaNode | undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextFromProperties = currentSchema.properties?.[segment];
|
||||
if (nextFromProperties) {
|
||||
currentSchema = nextFromProperties as ErrorSchemaNode;
|
||||
continue;
|
||||
}
|
||||
|
||||
const additionalProperties = currentSchema.additionalProperties;
|
||||
if (
|
||||
additionalProperties &&
|
||||
typeof additionalProperties === "object" &&
|
||||
!Array.isArray(additionalProperties)
|
||||
) {
|
||||
currentSchema = additionalProperties as ErrorSchemaNode;
|
||||
continue;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return currentSchema;
|
||||
};
|
||||
|
||||
const resolveErrorFieldLabel = ({
|
||||
error,
|
||||
schema,
|
||||
formContext,
|
||||
t,
|
||||
i18n,
|
||||
}: {
|
||||
error: RJSFValidationError;
|
||||
schema: RJSFSchema | undefined;
|
||||
formContext?: ConfigFormContext;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
i18n: ReturnType<typeof useTranslation>["i18n"];
|
||||
}): string | undefined => {
|
||||
const segments = parsePropertyPath(error.property || "");
|
||||
if (segments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stringSegments = segments.filter(
|
||||
(segment): segment is string => typeof segment === "string",
|
||||
);
|
||||
|
||||
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
|
||||
const effectiveNamespace =
|
||||
formContext?.level === "camera"
|
||||
? "config/cameras"
|
||||
: formContext?.i18nNamespace;
|
||||
|
||||
const translationPath = buildTranslationPath(
|
||||
stringSegments,
|
||||
sectionI18nPrefix,
|
||||
formContext,
|
||||
);
|
||||
|
||||
if (effectiveNamespace && translationPath) {
|
||||
const prefixedTranslationKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.label`
|
||||
: undefined;
|
||||
const translationKey = `${translationPath}.label`;
|
||||
|
||||
if (
|
||||
prefixedTranslationKey &&
|
||||
i18n.exists(prefixedTranslationKey, { ns: effectiveNamespace })
|
||||
) {
|
||||
return t(prefixedTranslationKey, { ns: effectiveNamespace });
|
||||
}
|
||||
|
||||
if (i18n.exists(translationKey, { ns: effectiveNamespace })) {
|
||||
return t(translationKey, { ns: effectiveNamespace });
|
||||
}
|
||||
}
|
||||
|
||||
const schemaNode = resolveSchemaNodeForPath(schema, segments);
|
||||
if (schemaNode?.title && schemaNode.title.trim().length > 0) {
|
||||
return schemaNode.title;
|
||||
}
|
||||
|
||||
const fallbackSegment =
|
||||
[...stringSegments].reverse().find((segment) => segment.length > 0) ||
|
||||
(typeof segments[segments.length - 1] === "string"
|
||||
? (segments[segments.length - 1] as string)
|
||||
: undefined);
|
||||
|
||||
return fallbackSegment ? humanizeKey(fallbackSegment) : undefined;
|
||||
};
|
||||
|
||||
export function ErrorListTemplate(props: ErrorListProps) {
|
||||
const { errors, schema } = props;
|
||||
const formContext = (
|
||||
props as { registry?: { formContext?: ConfigFormContext } }
|
||||
).registry?.formContext;
|
||||
const { t, i18n } = useTranslation([
|
||||
formContext?.level === "camera"
|
||||
? "config/cameras"
|
||||
: formContext?.i18nNamespace || "config/global",
|
||||
"common",
|
||||
]);
|
||||
|
||||
if (!errors || errors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<LuCircleAlert className="h-4 w-4" />
|
||||
<AlertTitle>{t("validation_errors", { ns: "common" })}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="mt-2 list-inside list-disc space-y-1">
|
||||
{errors.map((error: RJSFValidationError, index: number) => {
|
||||
const fieldLabel = resolveErrorFieldLabel({
|
||||
error,
|
||||
schema,
|
||||
formContext,
|
||||
t,
|
||||
i18n,
|
||||
});
|
||||
|
||||
return (
|
||||
<li key={index} className="text-sm">
|
||||
{fieldLabel && (
|
||||
<span className="font-medium">{fieldLabel}: </span>
|
||||
)}
|
||||
{error.message}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
// Field Template - wraps each form field with label and description
|
||||
import { FieldTemplateProps, StrictRJSFSchema, UiSchema } from "@rjsf/utils";
|
||||
import {
|
||||
getTemplate,
|
||||
getUiOptions,
|
||||
ADDITIONAL_PROPERTY_FLAG,
|
||||
} from "@rjsf/utils";
|
||||
import { ComponentType, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isNullableUnionSchema } from "../fields/nullableUtils";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import {
|
||||
buildTranslationPath,
|
||||
getFilterObjectLabel,
|
||||
hasOverrideAtPath,
|
||||
humanizeKey,
|
||||
normalizeFieldValue,
|
||||
} from "../utils";
|
||||
import { normalizeOverridePath } from "../utils/overrides";
|
||||
import get from "lodash/get";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { SPLIT_ROW_CLASS_NAME } from "@/components/card/SettingsGroupCard";
|
||||
|
||||
function _isArrayItemInAdditionalProperty(
|
||||
pathSegments: Array<string | number>,
|
||||
): boolean {
|
||||
// // If we find a numeric index, this is an array item
|
||||
for (let i = 0; i < pathSegments.length; i++) {
|
||||
const segment = pathSegments[i];
|
||||
if (typeof segment === "number") {
|
||||
// Consider any array item as being inside additional properties if it's not at the root level
|
||||
return i > 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type FieldRenderSpec =
|
||||
| ReactNode
|
||||
| ComponentType<unknown>
|
||||
| {
|
||||
render: string;
|
||||
props?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function FieldTemplate(props: FieldTemplateProps) {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
classNames,
|
||||
style,
|
||||
errors,
|
||||
help,
|
||||
description,
|
||||
hidden,
|
||||
required,
|
||||
displayLabel,
|
||||
schema,
|
||||
uiSchema,
|
||||
registry,
|
||||
fieldPathId,
|
||||
onKeyRename,
|
||||
onKeyRenameBlur,
|
||||
onRemoveProperty,
|
||||
rawDescription,
|
||||
rawErrors,
|
||||
formData: fieldFormData,
|
||||
disabled,
|
||||
readonly,
|
||||
} = props;
|
||||
|
||||
// Get i18n namespace from form context (passed through registry)
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
|
||||
const sectionI18nPrefix = formContext?.sectionI18nPrefix as
|
||||
| string
|
||||
| undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
|
||||
const { t, i18n } = useTranslation([
|
||||
effectiveNamespace || i18nNamespace || "common",
|
||||
i18nNamespace || "common",
|
||||
"views/settings",
|
||||
]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
|
||||
if (hidden) {
|
||||
return <div className="hidden">{children}</div>;
|
||||
}
|
||||
|
||||
// Get UI options
|
||||
const uiOptionsFromSchema = uiSchema?.["ui:options"] || {};
|
||||
|
||||
const suppressDescription = uiOptionsFromSchema.suppressDescription === true;
|
||||
const showArrayItemDescription =
|
||||
uiOptionsFromSchema.showArrayItemDescription === true;
|
||||
|
||||
// Determine field characteristics
|
||||
const isBoolean =
|
||||
schema.type === "boolean" ||
|
||||
(Array.isArray(schema.type) && schema.type.includes("boolean"));
|
||||
const isObjectField =
|
||||
schema.type === "object" ||
|
||||
(Array.isArray(schema.type) && schema.type.includes("object"));
|
||||
const isNullableUnion = isNullableUnionSchema(schema as StrictRJSFSchema);
|
||||
const isAdditionalProperty = ADDITIONAL_PROPERTY_FLAG in schema;
|
||||
const suppressMultiSchema =
|
||||
(uiSchema?.["ui:options"] as UiSchema["ui:options"] | undefined)
|
||||
?.suppressMultiSchema === true;
|
||||
const schemaTypes = Array.isArray(schema.type)
|
||||
? schema.type
|
||||
: schema.type
|
||||
? [schema.type]
|
||||
: [];
|
||||
const nonNullSchemaTypes = schemaTypes.filter((type) => type !== "null");
|
||||
const isScalarValueField =
|
||||
nonNullSchemaTypes.length === 1 &&
|
||||
["string", "number", "integer"].includes(nonNullSchemaTypes[0]);
|
||||
|
||||
// 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);
|
||||
const useSplitBooleanLayout =
|
||||
uiOptionsFromSchema.splitLayout !== false &&
|
||||
isBoolean &&
|
||||
!isMultiSchemaWrapper &&
|
||||
!isObjectField &&
|
||||
!isAdditionalProperty;
|
||||
const forceSplitLayout = uiOptionsFromSchema.forceSplitLayout === true;
|
||||
const useSplitLayout =
|
||||
uiOptionsFromSchema.splitLayout !== false &&
|
||||
(isScalarValueField || forceSplitLayout) &&
|
||||
!isBoolean &&
|
||||
!isMultiSchemaWrapper &&
|
||||
!isObjectField &&
|
||||
!isAdditionalProperty;
|
||||
|
||||
// Get translation path for this field
|
||||
const pathSegments = fieldPathId.path.filter(
|
||||
(segment): segment is string => typeof segment === "string",
|
||||
);
|
||||
|
||||
// Check if this is an array item inside an object with additionalProperties
|
||||
const isArrayItemInAdditionalProp = _isArrayItemInAdditionalProperty(
|
||||
fieldPathId.path,
|
||||
);
|
||||
|
||||
// Conditions for showing descriptions/docs links
|
||||
const shouldShowDescription =
|
||||
!isMultiSchemaWrapper &&
|
||||
!isObjectField &&
|
||||
!isAdditionalProperty &&
|
||||
(!isArrayItemInAdditionalProp || showArrayItemDescription) &&
|
||||
!suppressDescription;
|
||||
|
||||
const translationPath = buildTranslationPath(
|
||||
pathSegments,
|
||||
sectionI18nPrefix,
|
||||
formContext,
|
||||
);
|
||||
const fieldPath = fieldPathId.path;
|
||||
const overrides = formContext?.overrides;
|
||||
const baselineFormData = formContext?.baselineFormData;
|
||||
const normalizedFieldPath = normalizeOverridePath(
|
||||
fieldPath,
|
||||
formContext?.formData,
|
||||
);
|
||||
let baselineValue = baselineFormData
|
||||
? get(baselineFormData, normalizedFieldPath)
|
||||
: undefined;
|
||||
if (baselineValue === undefined || baselineValue === null) {
|
||||
if (schema.default !== undefined && schema.default !== null) {
|
||||
baselineValue = schema.default;
|
||||
}
|
||||
}
|
||||
const isBaselineModified =
|
||||
baselineFormData !== undefined &&
|
||||
!isEqual(
|
||||
normalizeFieldValue(fieldFormData),
|
||||
normalizeFieldValue(baselineValue),
|
||||
);
|
||||
const isModified = baselineFormData
|
||||
? isBaselineModified
|
||||
: hasOverrideAtPath(overrides, fieldPath, formContext?.formData);
|
||||
const filterObjectLabel = getFilterObjectLabel(pathSegments);
|
||||
const translatedFilterObjectLabel = filterObjectLabel
|
||||
? getTranslatedLabel(filterObjectLabel, "object")
|
||||
: undefined;
|
||||
const fieldDocsKey = translationPath || pathSegments.join(".");
|
||||
const fieldDocsPath = fieldDocsKey
|
||||
? formContext?.fieldDocs?.[fieldDocsKey]
|
||||
: undefined;
|
||||
const fieldDocsUrl = fieldDocsPath
|
||||
? getLocaleDocUrl(fieldDocsPath)
|
||||
: undefined;
|
||||
const restartRequired = formContext?.restartRequired;
|
||||
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
|
||||
const fieldRequiresRestart = requiresRestartForFieldPath(
|
||||
normalizedFieldPath,
|
||||
restartRequired,
|
||||
defaultRequiresRestart,
|
||||
);
|
||||
|
||||
// Use schema title/description as primary source (from JSON Schema)
|
||||
const schemaTitle = schema.title;
|
||||
const schemaDescription = schema.description;
|
||||
|
||||
// Try to get translated label, falling back to schema title, then RJSF label
|
||||
let finalLabel = label;
|
||||
if (effectiveNamespace && translationPath) {
|
||||
// Prefer camera-scoped translations when a section prefix is provided
|
||||
const prefixedTranslationKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.label`
|
||||
: undefined;
|
||||
const translationKey = `${translationPath}.label`;
|
||||
|
||||
if (
|
||||
prefixedTranslationKey &&
|
||||
i18n.exists(prefixedTranslationKey, { ns: effectiveNamespace })
|
||||
) {
|
||||
finalLabel = t(prefixedTranslationKey, { ns: effectiveNamespace });
|
||||
} else if (i18n.exists(translationKey, { ns: effectiveNamespace })) {
|
||||
finalLabel = t(translationKey, { ns: effectiveNamespace });
|
||||
} else if (schemaTitle) {
|
||||
finalLabel = schemaTitle;
|
||||
} else if (translatedFilterObjectLabel) {
|
||||
const filtersIndex = pathSegments.indexOf("filters");
|
||||
const isFilterObjectField =
|
||||
filtersIndex > -1 && pathSegments.length === filtersIndex + 2;
|
||||
|
||||
if (isFilterObjectField) {
|
||||
finalLabel = translatedFilterObjectLabel;
|
||||
} else {
|
||||
// Try to get translated field label, fall back to humanized
|
||||
const fieldName = pathSegments[pathSegments.length - 1] || "";
|
||||
let fieldLabel = schemaTitle;
|
||||
if (!fieldLabel) {
|
||||
const fieldTranslationKey = `${fieldName}.label`;
|
||||
const prefixedFieldTranslationKey =
|
||||
sectionI18nPrefix &&
|
||||
!fieldTranslationKey.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${fieldTranslationKey}`
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
prefixedFieldTranslationKey &&
|
||||
effectiveNamespace &&
|
||||
i18n.exists(prefixedFieldTranslationKey, { ns: effectiveNamespace })
|
||||
) {
|
||||
fieldLabel = t(prefixedFieldTranslationKey, {
|
||||
ns: effectiveNamespace,
|
||||
});
|
||||
} else if (
|
||||
effectiveNamespace &&
|
||||
i18n.exists(fieldTranslationKey, { ns: effectiveNamespace })
|
||||
) {
|
||||
fieldLabel = t(fieldTranslationKey, { ns: effectiveNamespace });
|
||||
} else {
|
||||
fieldLabel = humanizeKey(fieldName);
|
||||
}
|
||||
}
|
||||
if (fieldLabel) {
|
||||
finalLabel = t("configForm.filters.objectFieldLabel", {
|
||||
ns: "views/settings",
|
||||
field: fieldLabel,
|
||||
label: translatedFilterObjectLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (schemaTitle) {
|
||||
finalLabel = schemaTitle;
|
||||
} else if (translatedFilterObjectLabel) {
|
||||
const filtersIndex = pathSegments.indexOf("filters");
|
||||
const isFilterObjectField =
|
||||
filtersIndex > -1 && pathSegments.length === filtersIndex + 2;
|
||||
if (isFilterObjectField) {
|
||||
finalLabel = translatedFilterObjectLabel;
|
||||
} else {
|
||||
// Try to get translated field label, fall back to humanized
|
||||
const fieldName = pathSegments[pathSegments.length - 1] || "";
|
||||
let fieldLabel = schemaTitle;
|
||||
if (!fieldLabel) {
|
||||
const fieldTranslationKey = `${fieldName}.label`;
|
||||
const prefixedFieldTranslationKey =
|
||||
sectionI18nPrefix &&
|
||||
!fieldTranslationKey.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${fieldTranslationKey}`
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
prefixedFieldTranslationKey &&
|
||||
effectiveNamespace &&
|
||||
i18n.exists(prefixedFieldTranslationKey, { ns: effectiveNamespace })
|
||||
) {
|
||||
fieldLabel = t(prefixedFieldTranslationKey, {
|
||||
ns: effectiveNamespace,
|
||||
});
|
||||
} else if (
|
||||
effectiveNamespace &&
|
||||
i18n.exists(fieldTranslationKey, { ns: effectiveNamespace })
|
||||
) {
|
||||
fieldLabel = t(fieldTranslationKey, { ns: effectiveNamespace });
|
||||
} else {
|
||||
fieldLabel = humanizeKey(fieldName);
|
||||
}
|
||||
}
|
||||
if (fieldLabel) {
|
||||
finalLabel = t("configForm.filters.objectFieldLabel", {
|
||||
ns: "views/settings",
|
||||
field: fieldLabel,
|
||||
label: translatedFilterObjectLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get translated description, falling back to schema description
|
||||
let finalDescription = description || "";
|
||||
if (effectiveNamespace && translationPath) {
|
||||
const prefixedDescriptionKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.description`
|
||||
: undefined;
|
||||
const descriptionKey = `${translationPath}.description`;
|
||||
if (
|
||||
prefixedDescriptionKey &&
|
||||
i18n.exists(prefixedDescriptionKey, { ns: effectiveNamespace })
|
||||
) {
|
||||
finalDescription = t(prefixedDescriptionKey, { ns: effectiveNamespace });
|
||||
} else if (i18n.exists(descriptionKey, { ns: effectiveNamespace })) {
|
||||
finalDescription = t(descriptionKey, { ns: effectiveNamespace });
|
||||
} else if (schemaDescription) {
|
||||
finalDescription = schemaDescription;
|
||||
}
|
||||
} else if (schemaDescription) {
|
||||
finalDescription = schemaDescription;
|
||||
}
|
||||
|
||||
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 ?? {})} formContext={formContext} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const beforeContent = renderCustom(beforeSpec);
|
||||
const afterContent = renderCustom(afterSpec);
|
||||
const WrapIfAdditionalTemplate = getTemplate(
|
||||
"WrapIfAdditionalTemplate",
|
||||
registry,
|
||||
uiOptions,
|
||||
);
|
||||
|
||||
const shouldRenderStandardLabel =
|
||||
displayLabel &&
|
||||
finalLabel &&
|
||||
!isBoolean &&
|
||||
!useSplitLayout &&
|
||||
!isMultiSchemaWrapper &&
|
||||
!isObjectField &&
|
||||
!isAdditionalProperty;
|
||||
|
||||
const shouldRenderSplitLabel =
|
||||
displayLabel &&
|
||||
finalLabel &&
|
||||
!isMultiSchemaWrapper &&
|
||||
!isObjectField &&
|
||||
!isAdditionalProperty;
|
||||
|
||||
const shouldRenderBooleanLabel = displayLabel && finalLabel;
|
||||
|
||||
const renderDocsLink = (className?: string) => {
|
||||
if (!fieldDocsUrl || !shouldShowDescription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center text-xs text-primary-variant",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
to={fieldDocsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
>
|
||||
{t("readTheDocumentation", { ns: "common" })}
|
||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDescription = (className?: string) => {
|
||||
if (!finalDescription || !shouldShowDescription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className={cn("text-xs text-muted-foreground", className)}>
|
||||
{finalDescription}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStandardLabel = () => {
|
||||
if (!shouldRenderStandardLabel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isModified && "text-danger",
|
||||
errors && errors.props?.errors?.length > 0 && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBooleanLabel = () => {
|
||||
if (!shouldRenderBooleanLabel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={cn("text-sm font-medium", isModified && "text-danger")}
|
||||
>
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSplitLabel = () => {
|
||||
if (!shouldRenderSplitLabel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isModified && "text-danger",
|
||||
errors && errors.props?.errors?.length > 0 && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBooleanSplitLayout = () => (
|
||||
<>
|
||||
<div className="space-y-1.5 md:hidden">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{renderBooleanLabel()}
|
||||
<div className="flex items-center gap-2">{children}</div>
|
||||
</div>
|
||||
{renderDescription()}
|
||||
{renderDocsLink()}
|
||||
</div>
|
||||
|
||||
<div className={cn("hidden md:grid", SPLIT_ROW_CLASS_NAME)}>
|
||||
<div className="space-y-0.5">
|
||||
{renderBooleanLabel()}
|
||||
{renderDescription()}
|
||||
{renderDocsLink()}
|
||||
</div>
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="flex items-center gap-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderBooleanInlineLayout = () => (
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
{renderBooleanLabel()}
|
||||
{renderDescription()}
|
||||
{renderDocsLink()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSplitValueLayout = () => (
|
||||
<div className={cn(SPLIT_ROW_CLASS_NAME, "space-y-1.5 md:space-y-3")}>
|
||||
<div className="space-y-1.5">
|
||||
{renderSplitLabel()}
|
||||
{renderDescription("hidden md:block")}
|
||||
{renderDocsLink("hidden md:flex")}
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-2xl space-y-1">
|
||||
{children}
|
||||
{renderDescription("md:hidden")}
|
||||
{renderDocsLink("md:hidden")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDefaultValueLayout = () => (
|
||||
<>
|
||||
{children}
|
||||
{renderDescription()}
|
||||
{renderDocsLink()}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderFieldLayout = () => {
|
||||
if (isBoolean) {
|
||||
return useSplitBooleanLayout
|
||||
? renderBooleanSplitLayout()
|
||||
: renderBooleanInlineLayout();
|
||||
}
|
||||
|
||||
if (useSplitLayout) {
|
||||
return renderSplitValueLayout();
|
||||
}
|
||||
|
||||
return renderDefaultValueLayout();
|
||||
};
|
||||
|
||||
return (
|
||||
<WrapIfAdditionalTemplate
|
||||
classNames={classNames}
|
||||
style={style}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
label={label}
|
||||
displayLabel={displayLabel}
|
||||
onKeyRename={onKeyRename}
|
||||
onKeyRenameBlur={onKeyRenameBlur}
|
||||
onRemoveProperty={onRemoveProperty}
|
||||
rawDescription={rawDescription}
|
||||
readonly={readonly}
|
||||
required={required}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
registry={registry}
|
||||
rawErrors={rawErrors}
|
||||
hideError={false}
|
||||
>
|
||||
<div className="flex flex-col space-y-6">
|
||||
{beforeContent}
|
||||
<div className={cn("space-y-1")} data-field-id={translationPath}>
|
||||
{renderStandardLabel()}
|
||||
{renderFieldLayout()}
|
||||
|
||||
{errors}
|
||||
{help}
|
||||
</div>
|
||||
{afterContent}
|
||||
</div>
|
||||
</WrapIfAdditionalTemplate>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Custom MultiSchemaFieldTemplate to handle anyOf [Type, null] fields
|
||||
// Renders simple nullable types as single inputs instead of dropdowns
|
||||
|
||||
import {
|
||||
MultiSchemaFieldTemplateProps,
|
||||
StrictRJSFSchema,
|
||||
FormContextType,
|
||||
UiSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { isNullableUnionSchema } from "../fields/nullableUtils";
|
||||
|
||||
/**
|
||||
* Custom MultiSchemaFieldTemplate that:
|
||||
* 1. Renders simple anyOf [Type, null] fields as single inputs
|
||||
* 2. Falls back to default behavior for complex types
|
||||
*/
|
||||
export function MultiSchemaFieldTemplate<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
T = any,
|
||||
S extends StrictRJSFSchema = StrictRJSFSchema,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
F extends FormContextType = any,
|
||||
>(props: MultiSchemaFieldTemplateProps<T, S, F>): JSX.Element {
|
||||
const { schema, selector, optionSchemaField, uiSchema } = props;
|
||||
|
||||
const uiOptions = uiSchema?.["ui:options"] as
|
||||
| UiSchema["ui:options"]
|
||||
| undefined;
|
||||
const suppressMultiSchema = uiOptions?.suppressMultiSchema === true;
|
||||
|
||||
// Check if this is a simple nullable field that should be handled specially
|
||||
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}</>;
|
||||
}
|
||||
|
||||
// For all other cases, render with both selector and field (default MultiSchemaField behavior)
|
||||
return (
|
||||
<>
|
||||
{selector}
|
||||
{optionSchemaField}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
// Object Field Template - renders nested object fields with i18n support
|
||||
import type { ObjectFieldTemplateProps } from "@rjsf/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Children, useState, useEffect, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
import {
|
||||
buildTranslationPath,
|
||||
getDomainFromNamespace,
|
||||
getFilterObjectLabel,
|
||||
humanizeKey,
|
||||
isSubtreeModified,
|
||||
} from "../utils";
|
||||
import get from "lodash/get";
|
||||
import { AddPropertyButton, AdvancedCollapsible } from "../components";
|
||||
|
||||
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
properties,
|
||||
uiSchema,
|
||||
registry,
|
||||
schema,
|
||||
onAddProperty,
|
||||
formData,
|
||||
disabled,
|
||||
readonly,
|
||||
} = props;
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
|
||||
// Check if this is a root-level object
|
||||
const isRoot = registry?.rootSchema === schema;
|
||||
const overrides = formContext?.overrides;
|
||||
const baselineFormData = formContext?.baselineFormData;
|
||||
const hiddenFields = formContext?.hiddenFields;
|
||||
const fieldPath = props.fieldPathId.path;
|
||||
const restartRequired = formContext?.restartRequired;
|
||||
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
|
||||
|
||||
// Strip fields from an object that should be excluded from modification
|
||||
// detection: fields listed in hiddenFields (stripped from baseline by
|
||||
// sanitizeSectionData) and fields with ui:widget=hidden in uiSchema
|
||||
// (managed by custom components, not the standard form).
|
||||
const stripExcludedFields = (
|
||||
data: unknown,
|
||||
path: Array<string | number>,
|
||||
): unknown => {
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== "object" ||
|
||||
Array.isArray(data) ||
|
||||
data === null
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
const result = { ...(data as Record<string, unknown>) };
|
||||
const pathStrings = path.map(String);
|
||||
|
||||
// Strip hiddenFields that match the current path prefix
|
||||
if (hiddenFields) {
|
||||
for (const hidden of hiddenFields) {
|
||||
const parts = hidden.split(".");
|
||||
if (
|
||||
parts.length === pathStrings.length + 1 &&
|
||||
pathStrings.every((s, i) => s === parts[i])
|
||||
) {
|
||||
delete result[parts[parts.length - 1]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip ui:widget=hidden fields from uiSchema at this level
|
||||
if (uiSchema) {
|
||||
// Navigate to the uiSchema subtree matching the relative path
|
||||
let subUiSchema = uiSchema;
|
||||
const relativePath = path.slice(fieldPath.length);
|
||||
for (const segment of relativePath) {
|
||||
if (
|
||||
typeof segment === "string" &&
|
||||
subUiSchema &&
|
||||
typeof subUiSchema[segment] === "object"
|
||||
) {
|
||||
subUiSchema = subUiSchema[segment] as typeof uiSchema;
|
||||
} else {
|
||||
subUiSchema = undefined as unknown as typeof uiSchema;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (subUiSchema && typeof subUiSchema === "object") {
|
||||
for (const [key, propSchema] of Object.entries(subUiSchema)) {
|
||||
if (
|
||||
!key.startsWith("ui:") &&
|
||||
typeof propSchema === "object" &&
|
||||
propSchema !== null &&
|
||||
(propSchema as Record<string, unknown>)["ui:widget"] === "hidden"
|
||||
) {
|
||||
delete result[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Use props.formData (always up-to-date from RJSF) rather than
|
||||
// formContext.formData which can be stale in parent templates.
|
||||
const checkSubtreeModified = (path: Array<string | number>): boolean => {
|
||||
// Compute relative path from this object's fieldPath to get the
|
||||
// value from props.formData (which represents this object's data)
|
||||
const relativePath = path.slice(fieldPath.length);
|
||||
let currentValue =
|
||||
relativePath.length > 0 ? get(formData, relativePath) : formData;
|
||||
|
||||
// Strip hidden/excluded fields from the RJSF data before comparing
|
||||
// against the baseline (which already has these stripped)
|
||||
currentValue = stripExcludedFields(currentValue, path);
|
||||
|
||||
let baselineValue =
|
||||
path.length > 0 ? get(baselineFormData, path) : baselineFormData;
|
||||
// Also strip hidden/excluded fields from the baseline so that fields
|
||||
// managed by custom components (e.g. required_zones with ui:widget=hidden)
|
||||
// don't cause false modification detection.
|
||||
baselineValue = stripExcludedFields(baselineValue, path);
|
||||
|
||||
return isSubtreeModified(
|
||||
currentValue,
|
||||
baselineValue,
|
||||
overrides,
|
||||
path,
|
||||
formContext?.formData,
|
||||
);
|
||||
};
|
||||
|
||||
const hasModifiedDescendants = checkSubtreeModified(fieldPath);
|
||||
const [isOpen, setIsOpen] = useState(hasModifiedDescendants);
|
||||
const resetKey = `${formContext?.level ?? "global"}::${
|
||||
formContext?.cameraName ?? "global"
|
||||
}`;
|
||||
const lastResetKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Auto-expand collapsible when modifications are detected
|
||||
useEffect(() => {
|
||||
if (hasModifiedDescendants) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [hasModifiedDescendants]);
|
||||
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : "config/global";
|
||||
const sectionI18nPrefix = formContext?.sectionI18nPrefix;
|
||||
|
||||
const { t, i18n } = useTranslation([
|
||||
effectiveNamespace,
|
||||
"config/groups",
|
||||
"views/settings",
|
||||
"common",
|
||||
]);
|
||||
const objectRequiresRestart = requiresRestartForFieldPath(
|
||||
fieldPath,
|
||||
restartRequired,
|
||||
defaultRequiresRestart,
|
||||
);
|
||||
|
||||
const domain = getDomainFromNamespace(formContext?.i18nNamespace);
|
||||
|
||||
const groupDefinitions =
|
||||
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
|
||||
const disableNestedCard =
|
||||
uiSchema?.["ui:options"]?.disableNestedCard === true;
|
||||
|
||||
const isHiddenProp = (prop: (typeof properties)[number]) =>
|
||||
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
|
||||
|
||||
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
|
||||
|
||||
// Check for advanced section grouping
|
||||
const advancedProps = visibleProps.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
|
||||
);
|
||||
const regularProps = visibleProps.filter(
|
||||
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
|
||||
);
|
||||
const hasModifiedAdvanced = advancedProps.some((prop) =>
|
||||
checkSubtreeModified([...fieldPath, prop.name]),
|
||||
);
|
||||
const [showAdvanced, setShowAdvanced] = useState(hasModifiedAdvanced);
|
||||
|
||||
// Auto-expand advanced section when modifications are detected
|
||||
useEffect(() => {
|
||||
if (hasModifiedAdvanced) {
|
||||
setShowAdvanced(true);
|
||||
}
|
||||
}, [hasModifiedAdvanced]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastResetKeyRef.current !== resetKey) {
|
||||
lastResetKeyRef.current = resetKey;
|
||||
setIsOpen(hasModifiedDescendants);
|
||||
setShowAdvanced(hasModifiedAdvanced);
|
||||
}
|
||||
}, [resetKey, hasModifiedDescendants, hasModifiedAdvanced]);
|
||||
const { children } = props as ObjectFieldTemplateProps & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
const hasCustomChildren = Children.count(children) > 0;
|
||||
|
||||
// Get the full translation path from the field path
|
||||
const fieldPathId = (
|
||||
props as { fieldPathId?: { path?: (string | number)[] } }
|
||||
).fieldPathId;
|
||||
let propertyName: string | undefined;
|
||||
let translationPath: string | undefined;
|
||||
const path = fieldPathId?.path;
|
||||
const filterObjectLabel = path ? getFilterObjectLabel(path) : undefined;
|
||||
const translatedFilterLabel = filterObjectLabel
|
||||
? getTranslatedLabel(filterObjectLabel, "object")
|
||||
: undefined;
|
||||
if (path) {
|
||||
translationPath = buildTranslationPath(
|
||||
path,
|
||||
sectionI18nPrefix,
|
||||
formContext,
|
||||
);
|
||||
// Also get the last property name for fallback label generation
|
||||
for (let i = path.length - 1; i >= 0; i -= 1) {
|
||||
const segment = path[i];
|
||||
if (typeof segment === "string") {
|
||||
propertyName = segment;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try i18n translation, fall back to schema or original values
|
||||
const i18nNs = effectiveNamespace;
|
||||
|
||||
let inferredLabel: string | undefined;
|
||||
if (i18nNs && translationPath) {
|
||||
const prefixedLabelKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.label`
|
||||
: undefined;
|
||||
const labelKey = `${translationPath}.label`;
|
||||
if (prefixedLabelKey && i18n.exists(prefixedLabelKey, { ns: i18nNs })) {
|
||||
inferredLabel = t(prefixedLabelKey, { ns: i18nNs });
|
||||
} else if (i18n.exists(labelKey, { ns: i18nNs })) {
|
||||
inferredLabel = t(labelKey, { ns: i18nNs });
|
||||
}
|
||||
}
|
||||
if (!inferredLabel && translatedFilterLabel) {
|
||||
inferredLabel = translatedFilterLabel;
|
||||
}
|
||||
const schemaTitle = schema?.title;
|
||||
const fallbackLabel =
|
||||
title ||
|
||||
schemaTitle ||
|
||||
(propertyName ? humanizeKey(propertyName) : undefined);
|
||||
inferredLabel = inferredLabel ?? fallbackLabel;
|
||||
|
||||
let inferredDescription: string | undefined;
|
||||
if (i18nNs && translationPath) {
|
||||
const prefixedDescriptionKey =
|
||||
sectionI18nPrefix && !translationPath.startsWith(`${sectionI18nPrefix}.`)
|
||||
? `${sectionI18nPrefix}.${translationPath}.description`
|
||||
: undefined;
|
||||
const descriptionKey = `${translationPath}.description`;
|
||||
if (
|
||||
prefixedDescriptionKey &&
|
||||
i18n.exists(prefixedDescriptionKey, { ns: i18nNs })
|
||||
) {
|
||||
inferredDescription = t(prefixedDescriptionKey, { ns: i18nNs });
|
||||
} else if (i18n.exists(descriptionKey, { ns: i18nNs })) {
|
||||
inferredDescription = t(descriptionKey, { ns: i18nNs });
|
||||
}
|
||||
}
|
||||
const schemaDescription = schema?.description;
|
||||
const fallbackDescription =
|
||||
(typeof description === "string" ? description : undefined) ||
|
||||
schemaDescription;
|
||||
inferredDescription = inferredDescription ?? fallbackDescription;
|
||||
|
||||
const renderGroupedFields = (items: (typeof properties)[number][]) => {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const grouped = new Set<string>();
|
||||
const groups = Object.entries(groupDefinitions)
|
||||
.map(([groupKey, fields]) => {
|
||||
const ordered = fields
|
||||
.map((field) => items.find((item) => item.name === field))
|
||||
.filter(Boolean) as (typeof properties)[number][];
|
||||
|
||||
if (ordered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ordered.forEach((item) => grouped.add(item.name));
|
||||
|
||||
const label = domain
|
||||
? t(`${sectionI18nPrefix}.${domain}.${groupKey}`, {
|
||||
ns: "config/groups",
|
||||
defaultValue: humanizeKey(groupKey),
|
||||
})
|
||||
: t(`groups.${groupKey}`, {
|
||||
defaultValue: humanizeKey(groupKey),
|
||||
});
|
||||
|
||||
return {
|
||||
key: groupKey,
|
||||
label,
|
||||
items: ordered,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
items: (typeof properties)[number][];
|
||||
}>;
|
||||
|
||||
const ungrouped = items.filter((item) => !grouped.has(item.name));
|
||||
const isObjectLikeField = (item: (typeof properties)[number]) => {
|
||||
const fieldSchema = item.content.props.schema as
|
||||
| { type?: string | string[] }
|
||||
| undefined;
|
||||
return fieldSchema?.type === "object";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
className="space-y-4 rounded-lg border border-border/70 bg-card/30 p-4"
|
||||
>
|
||||
<div className="text-md border-b border-border/60 pb-4 font-semibold text-primary-variant">
|
||||
{group.label}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{group.items.map((element) => (
|
||||
<div key={element.name}>{element.content}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{ungrouped.length > 0 && (
|
||||
<div className={cn("space-y-6", groups.length > 0 && "pt-2")}>
|
||||
{ungrouped.map((element) => (
|
||||
<div
|
||||
key={element.name}
|
||||
className={cn(
|
||||
groups.length > 0 && !isObjectLikeField(element) && "px-4",
|
||||
)}
|
||||
>
|
||||
{element.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Root level renders children directly
|
||||
if (isRoot) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{hasCustomChildren ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{renderGroupedFields(regularProps)}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
isRoot
|
||||
>
|
||||
{renderGroupedFields(advancedProps)}
|
||||
</AdvancedCollapsible>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (disableNestedCard) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{hasCustomChildren ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{renderGroupedFields(regularProps)}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
>
|
||||
{renderGroupedFields(advancedProps)}
|
||||
</AdvancedCollapsible>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Nested objects render as collapsible cards
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle
|
||||
className={cn(
|
||||
"flex items-center text-sm",
|
||||
hasModifiedDescendants && "text-danger",
|
||||
)}
|
||||
>
|
||||
{inferredLabel}
|
||||
{objectRequiresRestart && (
|
||||
<RestartRequiredIndicator className="ml-2" />
|
||||
)}
|
||||
</CardTitle>
|
||||
{inferredDescription && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{inferredDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-6 p-4 pt-0">
|
||||
{hasCustomChildren ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{renderGroupedFields(regularProps)}
|
||||
<AddPropertyButton
|
||||
onAddProperty={onAddProperty}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
|
||||
<AdvancedCollapsible
|
||||
count={advancedProps.length}
|
||||
open={showAdvanced}
|
||||
onOpenChange={setShowAdvanced}
|
||||
>
|
||||
{renderGroupedFields(advancedProps)}
|
||||
</AdvancedCollapsible>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Title Field Template
|
||||
import type { TitleFieldProps } from "@rjsf/utils";
|
||||
|
||||
export function TitleFieldTemplate(props: TitleFieldProps) {
|
||||
const { title, id, required } = props;
|
||||
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<h3 id={id} className="text-lg font-semibold">
|
||||
{title}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
ADDITIONAL_PROPERTY_FLAG,
|
||||
FormContextType,
|
||||
getUiOptions,
|
||||
RJSFSchema,
|
||||
StrictRJSFSchema,
|
||||
WrapIfAdditionalTemplateProps,
|
||||
} from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTrash2 } from "react-icons/lu";
|
||||
|
||||
export function WrapIfAdditionalTemplate<
|
||||
T = unknown,
|
||||
S extends StrictRJSFSchema = RJSFSchema,
|
||||
F extends FormContextType = FormContextType,
|
||||
>(props: WrapIfAdditionalTemplateProps<T, S, F>) {
|
||||
const {
|
||||
classNames,
|
||||
style,
|
||||
children,
|
||||
disabled,
|
||||
id,
|
||||
label,
|
||||
displayLabel,
|
||||
onRemoveProperty,
|
||||
onKeyRenameBlur,
|
||||
readonly,
|
||||
required,
|
||||
schema,
|
||||
uiSchema,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const additional = ADDITIONAL_PROPERTY_FLAG in schema;
|
||||
|
||||
if (!additional) {
|
||||
return (
|
||||
<div className={classNames} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const keyId = `${id}-key`;
|
||||
const keyLabel = t("configForm.additionalProperties.keyLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const valueLabel = t("configForm.additionalProperties.valueLabel", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const keyPlaceholder = t("configForm.additionalProperties.keyPlaceholder", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const removeLabel = t("configForm.additionalProperties.remove", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const uiOptions = getUiOptions(uiSchema);
|
||||
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("grid grid-cols-12 items-start gap-2", classNames)}
|
||||
style={style}
|
||||
>
|
||||
{!keyIsReadonly && (
|
||||
<div className="col-span-12 space-y-2 md:col-span-2">
|
||||
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
|
||||
{keyIsReadonly ? (
|
||||
<div
|
||||
id={keyId}
|
||||
className="flex items-center text-sm text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id={keyId}
|
||||
name={keyId}
|
||||
required={required}
|
||||
defaultValue={label}
|
||||
placeholder={keyPlaceholder}
|
||||
disabled={disabled || readonly}
|
||||
onBlur={!readonly ? onKeyRenameBlur : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"col-span-12 space-y-2",
|
||||
!keyIsReadonly && "md:col-span-9",
|
||||
)}
|
||||
>
|
||||
{!keyIsReadonly && displayLabel && (
|
||||
<Label htmlFor={id}>{valueLabel}</Label>
|
||||
)}
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
{!keyIsReadonly && (
|
||||
<div className="col-span-12 flex items-center md:col-span-1 md:justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemoveProperty}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WrapIfAdditionalTemplate;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const FIELD_SIZE_CLASS_MAP = {
|
||||
xs: "max-w-xs",
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-2xl",
|
||||
full: "max-w-full",
|
||||
} as const;
|
||||
|
||||
export type FieldSizeOption = keyof typeof FIELD_SIZE_CLASS_MAP;
|
||||
|
||||
type FieldSizingOptions = {
|
||||
size?: FieldSizeOption;
|
||||
maxWidthClassName?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function getSizedFieldClassName(
|
||||
options: unknown,
|
||||
defaultSize: FieldSizeOption = "lg",
|
||||
) {
|
||||
const sizingOptions =
|
||||
typeof options === "object" && options !== null
|
||||
? (options as FieldSizingOptions)
|
||||
: undefined;
|
||||
|
||||
const sizeClass =
|
||||
FIELD_SIZE_CLASS_MAP[sizingOptions?.size ?? defaultSize] ??
|
||||
FIELD_SIZE_CLASS_MAP[defaultSize];
|
||||
|
||||
return cn(
|
||||
"w-full",
|
||||
sizingOptions?.maxWidthClassName ?? sizeClass,
|
||||
sizingOptions?.className,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Shared i18n utilities for config form templates and fields.
|
||||
*
|
||||
* These functions handle translation key path building and label normalization
|
||||
* for RJSF form fields.
|
||||
*/
|
||||
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const resolveDetectorType = (
|
||||
detectorConfig: unknown,
|
||||
detectorKey?: string,
|
||||
): string | undefined => {
|
||||
if (!detectorKey || !isRecord(detectorConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entry = detectorConfig[detectorKey];
|
||||
if (!isRecord(entry)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const typeValue = entry.type;
|
||||
return typeof typeValue === "string" && typeValue.length > 0
|
||||
? typeValue
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const resolveDetectorTypeFromContext = (
|
||||
formContext: ConfigFormContext | undefined,
|
||||
detectorKey?: string,
|
||||
): string | undefined => {
|
||||
const formData = formContext?.formData;
|
||||
if (!detectorKey || !isRecord(formData)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const detectorConfig = isRecord(formData.detectors)
|
||||
? formData.detectors
|
||||
: formData;
|
||||
|
||||
return resolveDetectorType(detectorConfig, detectorKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the i18n translation key path for nested fields using the field path
|
||||
* provided by RJSF. This avoids ambiguity with underscores in field names and
|
||||
* normalizes dynamic segments like filter object names or detector names.
|
||||
*
|
||||
* @param segments Array of path segments (strings and/or numbers)
|
||||
* @param sectionI18nPrefix Optional section prefix for specialized sections
|
||||
* @param formContext Optional form context for resolving detector types
|
||||
* @returns Normalized translation key path as a dot-separated string
|
||||
*
|
||||
* @example
|
||||
* buildTranslationPath(["filters", "person", "threshold"]) => "filters.threshold"
|
||||
* buildTranslationPath(["detectors", "ov1", "type"]) => "detectors.openvino.type"
|
||||
* buildTranslationPath(["ov1", "type"], "detectors") => "openvino.type"
|
||||
*/
|
||||
export function buildTranslationPath(
|
||||
segments: Array<string | number>,
|
||||
sectionI18nPrefix?: string,
|
||||
formContext?: ConfigFormContext,
|
||||
): string {
|
||||
// Filter out numeric indices to get string segments only
|
||||
const stringSegments = segments.filter(
|
||||
(segment): segment is string => typeof segment === "string",
|
||||
);
|
||||
|
||||
// Handle filters section - skip the dynamic filter object name
|
||||
// Example: filters.person.threshold -> filters.threshold
|
||||
const filtersIndex = stringSegments.indexOf("filters");
|
||||
if (filtersIndex !== -1 && stringSegments.length > filtersIndex + 2) {
|
||||
const normalized = [
|
||||
...stringSegments.slice(0, filtersIndex + 1),
|
||||
...stringSegments.slice(filtersIndex + 2),
|
||||
];
|
||||
return normalized.join(".");
|
||||
}
|
||||
|
||||
// Handle detectors section - resolve the detector type when available
|
||||
// Example: detectors.ov1.type -> detectors.openvino.type
|
||||
const detectorsIndex = stringSegments.indexOf("detectors");
|
||||
if (detectorsIndex !== -1 && stringSegments.length > detectorsIndex + 2) {
|
||||
const detectorKey = stringSegments[detectorsIndex + 1];
|
||||
const detectorType = resolveDetectorTypeFromContext(
|
||||
formContext,
|
||||
detectorKey,
|
||||
);
|
||||
if (detectorType) {
|
||||
const normalized = [
|
||||
...stringSegments.slice(0, detectorsIndex + 1),
|
||||
detectorType,
|
||||
...stringSegments.slice(detectorsIndex + 2),
|
||||
];
|
||||
return normalized.join(".");
|
||||
}
|
||||
|
||||
const normalized = [
|
||||
...stringSegments.slice(0, detectorsIndex + 1),
|
||||
...stringSegments.slice(detectorsIndex + 2),
|
||||
];
|
||||
return normalized.join(".");
|
||||
}
|
||||
|
||||
// Handle specialized sections like detectors where the first segment is dynamic
|
||||
// Example: (sectionI18nPrefix="detectors") "ov1.type" -> "openvino.type"
|
||||
if (sectionI18nPrefix === "detectors" && stringSegments.length > 1) {
|
||||
const detectorKey = stringSegments[0];
|
||||
const detectorType = resolveDetectorTypeFromContext(
|
||||
formContext,
|
||||
detectorKey,
|
||||
);
|
||||
if (detectorType) {
|
||||
return [detectorType, ...stringSegments.slice(1)].join(".");
|
||||
}
|
||||
|
||||
return stringSegments.slice(1).join(".");
|
||||
}
|
||||
|
||||
return stringSegments.join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the filter object label from a path containing "filters" segment.
|
||||
* Returns the segment immediately after "filters".
|
||||
*
|
||||
* @param pathSegments Array of path segments
|
||||
* @returns The filter object label or undefined if not found
|
||||
*
|
||||
* @example
|
||||
* getFilterObjectLabel(["filters", "person", "threshold"]) => "person"
|
||||
* getFilterObjectLabel(["detect", "enabled"]) => undefined
|
||||
*/
|
||||
export function getFilterObjectLabel(
|
||||
pathSegments: Array<string | number>,
|
||||
): string | undefined {
|
||||
const filtersIndex = pathSegments.indexOf("filters");
|
||||
if (filtersIndex === -1 || pathSegments.length <= filtersIndex + 1) {
|
||||
return undefined;
|
||||
}
|
||||
const objectLabel = pathSegments[filtersIndex + 1];
|
||||
return typeof objectLabel === "string" && objectLabel.length > 0
|
||||
? objectLabel
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert snake_case string to Title Case with spaces.
|
||||
* Useful for generating human-readable labels from schema property names.
|
||||
*
|
||||
* @param value The snake_case string to convert
|
||||
* @returns Title Case string
|
||||
*
|
||||
* @example
|
||||
* humanizeKey("detect_fps") => "Detect Fps"
|
||||
* humanizeKey("min_initialized") => "Min Initialized"
|
||||
*/
|
||||
export function humanizeKey(value: string): string {
|
||||
return value
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain name from an i18n namespace string.
|
||||
* Handles config/* namespace format by stripping the prefix.
|
||||
*
|
||||
* @param ns The i18n namespace (e.g., "config/audio", "config/global")
|
||||
* @returns The domain portion (e.g., "audio", "global") or empty string
|
||||
*
|
||||
* @example
|
||||
* getDomainFromNamespace("config/audio") => "audio"
|
||||
* getDomainFromNamespace("common") => ""
|
||||
*/
|
||||
export function getDomainFromNamespace(ns?: string): string {
|
||||
if (!ns || !ns.startsWith("config/")) return "";
|
||||
return ns.replace("config/", "");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Config form theme utilities
|
||||
*/
|
||||
|
||||
export {
|
||||
buildTranslationPath,
|
||||
getFilterObjectLabel,
|
||||
humanizeKey,
|
||||
getDomainFromNamespace,
|
||||
} from "./i18n";
|
||||
|
||||
export { getOverrideAtPath, hasOverrideAtPath } from "./overrides";
|
||||
export {
|
||||
deepNormalizeValue,
|
||||
normalizeFieldValue,
|
||||
isSubtreeModified,
|
||||
} from "./overrides";
|
||||
export { getSizedFieldClassName } from "./fieldSizing";
|
||||
@@ -0,0 +1,128 @@
|
||||
import get from "lodash/get";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import type { JsonValue } from "@/types/configForm";
|
||||
|
||||
export const getOverrideAtPath = (
|
||||
overrides: JsonValue | undefined,
|
||||
path: Array<string | number>,
|
||||
) => {
|
||||
if (overrides === undefined || overrides === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isJsonObject(overrides) || Array.isArray(overrides)) {
|
||||
return get(overrides, path);
|
||||
}
|
||||
|
||||
return path.length === 0 ? overrides : undefined;
|
||||
};
|
||||
|
||||
export const normalizeOverridePath = (
|
||||
path: Array<string | number>,
|
||||
data: JsonValue | undefined,
|
||||
) => {
|
||||
if (data === undefined || data === null) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const normalized: Array<string | number> = [];
|
||||
let cursor: JsonValue | undefined = data;
|
||||
|
||||
for (const segment of path) {
|
||||
if (typeof segment === "number") {
|
||||
if (Array.isArray(cursor)) {
|
||||
normalized.push(segment);
|
||||
cursor = cursor[segment] as JsonValue | undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.push(segment);
|
||||
|
||||
if (isJsonObject(cursor) || Array.isArray(cursor)) {
|
||||
cursor = (cursor as Record<string, JsonValue>)[segment];
|
||||
} else {
|
||||
cursor = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const hasOverrideAtPath = (
|
||||
overrides: JsonValue | undefined,
|
||||
path: Array<string | number>,
|
||||
contextData?: JsonValue,
|
||||
) => {
|
||||
const normalizedPath = contextData
|
||||
? normalizeOverridePath(path, contextData)
|
||||
: path;
|
||||
const value = getOverrideAtPath(overrides, normalizedPath);
|
||||
if (value !== undefined) {
|
||||
return true;
|
||||
}
|
||||
const shouldFallback =
|
||||
normalizedPath.length !== path.length ||
|
||||
normalizedPath.some((segment, index) => segment !== path[index]);
|
||||
if (!shouldFallback) {
|
||||
return false;
|
||||
}
|
||||
return getOverrideAtPath(overrides, path) !== undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deep normalization for form data comparison. Strips null, undefined,
|
||||
* and empty-string values from objects and arrays so that RJSF-injected
|
||||
* schema defaults (e.g., `mask: null`) don't cause false positives
|
||||
* against a baseline that lacks those keys.
|
||||
*/
|
||||
export const deepNormalizeValue = (value: unknown): unknown => {
|
||||
if (value === null || value === undefined || value === "") return undefined;
|
||||
if (Array.isArray(value)) return value.map(deepNormalizeValue);
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
const normalized = deepNormalizeValue(v);
|
||||
if (normalized !== undefined) {
|
||||
result[k] = normalized;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shallow normalization for individual field values.
|
||||
* Treats null and empty-string as equivalent to undefined.
|
||||
*/
|
||||
export const normalizeFieldValue = (value: unknown): unknown =>
|
||||
value === null || value === "" ? undefined : value;
|
||||
|
||||
/**
|
||||
* Check whether a subtree of form data has been modified relative to
|
||||
* the baseline. Uses deep normalization to ignore RJSF-injected null/empty
|
||||
* schema defaults.
|
||||
*
|
||||
* @param currentData - The current value at the subtree (from props.formData)
|
||||
* @param baselineData - The baseline value at the subtree (from formContext.baselineFormData)
|
||||
* @param overrides - Fallback: the overrides object from formContext
|
||||
* @param path - The full field path for the fallback override check
|
||||
* @param contextData - The full form data for normalizing the override path
|
||||
*/
|
||||
export const isSubtreeModified = (
|
||||
currentData: unknown,
|
||||
baselineData: unknown,
|
||||
overrides: JsonValue | undefined,
|
||||
path: Array<string | number>,
|
||||
contextData?: JsonValue,
|
||||
): boolean => {
|
||||
if (baselineData !== undefined || currentData !== undefined) {
|
||||
return !isEqual(
|
||||
deepNormalizeValue(currentData),
|
||||
deepNormalizeValue(baselineData),
|
||||
);
|
||||
}
|
||||
return hasOverrideAtPath(overrides, path, contextData);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
// Widget that displays an array as a concatenated text string
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function ArrayAsTextWidget(props: WidgetProps) {
|
||||
const { value, onChange, disabled, readonly, placeholder } = props;
|
||||
|
||||
// Convert array or string to text
|
||||
let textValue = "";
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
textValue = value;
|
||||
} else if (Array.isArray(value) && value.length > 0) {
|
||||
textValue = value.join(" ");
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newText = event.target.value;
|
||||
// Convert space-separated string back to array
|
||||
const newArray = newText.trim() ? newText.trim().split(/\s+/) : [];
|
||||
onChange(newArray);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={textValue}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
readOnly={readonly}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Audio Label Switches Widget - For selecting audio labels via switches
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import { SwitchesWidget } from "./SwitchesWidget";
|
||||
import type { FormContext } from "./SwitchesWidget";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { JsonObject } from "@/types/configForm";
|
||||
|
||||
function getEnabledAudioLabels(context: FormContext): string[] {
|
||||
let cameraLabels: string[] = [];
|
||||
let globalLabels: string[] = [];
|
||||
|
||||
if (context) {
|
||||
// context.cameraValue and context.globalValue should be the entire audio section
|
||||
if (
|
||||
context.cameraValue &&
|
||||
typeof context.cameraValue === "object" &&
|
||||
!Array.isArray(context.cameraValue)
|
||||
) {
|
||||
const listenValue = (context.cameraValue as JsonObject).listen;
|
||||
if (Array.isArray(listenValue)) {
|
||||
cameraLabels = listenValue.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
context.globalValue &&
|
||||
typeof context.globalValue === "object" &&
|
||||
!Array.isArray(context.globalValue)
|
||||
) {
|
||||
const globalListenValue = (context.globalValue as JsonObject).listen;
|
||||
if (Array.isArray(globalListenValue)) {
|
||||
globalLabels = globalListenValue.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
|
||||
return [...sourceLabels].sort();
|
||||
}
|
||||
|
||||
function getAudioLabelDisplayName(label: string): string {
|
||||
return getTranslatedLabel(label, "audio");
|
||||
}
|
||||
|
||||
export function AudioLabelSwitchesWidget(props: WidgetProps) {
|
||||
const { data: audioLabels } = useSWR<Record<string, string>>("/audio_labels");
|
||||
|
||||
const allLabels = useMemo(() => {
|
||||
if (!audioLabels) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const labelSet = new Set<string>();
|
||||
Object.values(audioLabels).forEach((label) => {
|
||||
if (typeof label !== "string") {
|
||||
return;
|
||||
}
|
||||
const normalized = label.trim();
|
||||
if (normalized) {
|
||||
labelSet.add(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
return [...labelSet].sort();
|
||||
}, [audioLabels]);
|
||||
|
||||
const getEntities = useCallback(
|
||||
(context: FormContext) => {
|
||||
const enabledLabels = getEnabledAudioLabels(context);
|
||||
|
||||
if (allLabels.length === 0) {
|
||||
return enabledLabels;
|
||||
}
|
||||
|
||||
const combinedLabels = new Set([...allLabels, ...enabledLabels]);
|
||||
return [...combinedLabels].sort();
|
||||
},
|
||||
[allLabels],
|
||||
);
|
||||
|
||||
return (
|
||||
<SwitchesWidget
|
||||
{...props}
|
||||
options={{
|
||||
...props.options,
|
||||
getEntities,
|
||||
getDisplayLabel: getAudioLabelDisplayName,
|
||||
i18nKey: "audioLabels",
|
||||
listClassName:
|
||||
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
enableSearch: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import useSWR from "swr";
|
||||
import { useMemo, useState, type FocusEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
type RawPathsResponse = {
|
||||
cameras?: Record<
|
||||
string,
|
||||
{
|
||||
ffmpeg?: {
|
||||
inputs?: Array<{
|
||||
path?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
|
||||
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
|
||||
|
||||
const getInputIndexFromWidgetId = (id: string): number | undefined => {
|
||||
const match = id.match(/_inputs_(\d+)_path$/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const index = Number(match[1]);
|
||||
return Number.isNaN(index) ? undefined : index;
|
||||
};
|
||||
|
||||
const isMaskedPath = (value: string): boolean =>
|
||||
MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value);
|
||||
|
||||
const hasCredentials = (value: string): boolean => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isMaskedPath(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.username || parsed.password) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
parsed.searchParams.has("user") && parsed.searchParams.has("password")
|
||||
);
|
||||
} catch {
|
||||
return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value);
|
||||
}
|
||||
};
|
||||
|
||||
const maskCredentials = (value: string): string => {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
|
||||
|
||||
return maskedAuth
|
||||
.replace(/([?&]user=)[^&]*/gi, "$1*")
|
||||
.replace(/([?&]password=)[^&]*/gi, "$1*");
|
||||
};
|
||||
|
||||
export function CameraPathWidget(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
schema,
|
||||
options,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation(["common", "views/settings"]);
|
||||
const [showCredentials, setShowCredentials] = useState(false);
|
||||
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const cameraName = formContext?.cameraName;
|
||||
const inputIndex = useMemo(() => getInputIndexFromWidgetId(id), [id]);
|
||||
|
||||
const shouldFetchRawPaths =
|
||||
isCameraLevel && !!cameraName && inputIndex !== undefined;
|
||||
const { data: rawPaths } = useSWR<RawPathsResponse>(
|
||||
shouldFetchRawPaths ? "config/raw_paths" : null,
|
||||
);
|
||||
|
||||
const rawPath = useMemo(() => {
|
||||
if (!cameraName || inputIndex === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const path =
|
||||
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path;
|
||||
return typeof path === "string" ? path : undefined;
|
||||
}, [cameraName, inputIndex, rawPaths]);
|
||||
|
||||
const rawValue = typeof value === "string" ? value : "";
|
||||
const resolvedValue =
|
||||
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
|
||||
const canReveal =
|
||||
hasCredentials(resolvedValue) && !isMaskedPath(resolvedValue);
|
||||
const canToggle = canReveal || isMaskedPath(rawValue);
|
||||
|
||||
const isMaskedView = canToggle && !showCredentials;
|
||||
const displayValue = isMaskedView
|
||||
? maskCredentials(resolvedValue)
|
||||
: resolvedValue;
|
||||
|
||||
const isNullable = Array.isArray(schema.type)
|
||||
? schema.type.includes("null")
|
||||
: false;
|
||||
|
||||
const fieldClassName = getSizedFieldClassName(options, "xs");
|
||||
const uriLabel = t("cameraWizard.step3.url", {
|
||||
ns: "views/settings",
|
||||
defaultValue: schema.title,
|
||||
});
|
||||
const toggleLabel = showCredentials
|
||||
? t("label.hide", { ns: "common", item: uriLabel })
|
||||
: t("label.show", { ns: "common", item: uriLabel });
|
||||
|
||||
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
|
||||
if (isMaskedView && canReveal) {
|
||||
setShowCredentials(true);
|
||||
onFocus(id, resolvedValue);
|
||||
return;
|
||||
}
|
||||
|
||||
onFocus(id, event.target.value);
|
||||
};
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
||||
if (canToggle) {
|
||||
setShowCredentials(false);
|
||||
}
|
||||
|
||||
onBlur(id, event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative", fieldClassName)}>
|
||||
<Input
|
||||
id={id}
|
||||
className={cn("text-md", canToggle ? "pr-10" : undefined)}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || (options.placeholder as string) || ""}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.value === ""
|
||||
? isNullable
|
||||
? null
|
||||
: undefined
|
||||
: e.target.value,
|
||||
)
|
||||
}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
aria-label={schema.title}
|
||||
/>
|
||||
|
||||
{canToggle ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowCredentials((previous) => !previous)}
|
||||
disabled={disabled || (!canReveal && !showCredentials)}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
>
|
||||
{showCredentials ? (
|
||||
<LuEyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<LuEye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Checkbox Widget - maps to shadcn/ui Checkbox
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
export function CheckboxWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, label, schema } = props;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={typeof value === "undefined" ? false : value}
|
||||
disabled={disabled || readonly}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
aria-label={label || schema.title || "Checkbox"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Color Widget - For RGB color objects
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
interface RGBColor {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
}
|
||||
|
||||
export function ColorWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange } = props;
|
||||
|
||||
// Convert object to hex for color picker
|
||||
const hexValue = useMemo(() => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return "#ffffff";
|
||||
}
|
||||
const { red = 255, green = 255, blue = 255 } = value as RGBColor;
|
||||
return `#${red.toString(16).padStart(2, "0")}${green.toString(16).padStart(2, "0")}${blue.toString(16).padStart(2, "0")}`;
|
||||
}, [value]);
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const hex = e.target.value;
|
||||
const red = parseInt(hex.slice(1, 3), 16);
|
||||
const green = parseInt(hex.slice(3, 5), 16);
|
||||
const blue = parseInt(hex.slice(5, 7), 16);
|
||||
onChange({ red, green, blue });
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id={id}
|
||||
type="color"
|
||||
value={hexValue}
|
||||
disabled={disabled || readonly}
|
||||
onChange={handleColorChange}
|
||||
className="h-10 w-14 cursor-pointer p-1"
|
||||
/>
|
||||
<div className="flex gap-2 text-sm text-muted-foreground">
|
||||
<Label>R: {(value as RGBColor)?.red ?? 255}</Label>
|
||||
<Label>G: {(value as RGBColor)?.green ?? 255}</Label>
|
||||
<Label>B: {(value as RGBColor)?.blue ?? 255}</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import useSWR from "swr";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
||||
type FfmpegPresetResponse = {
|
||||
hwaccel_args: string[];
|
||||
input_args: string[];
|
||||
output_args: {
|
||||
record: string[];
|
||||
detect: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type FfmpegArgsMode = "preset" | "manual" | "inherit";
|
||||
|
||||
type PresetField =
|
||||
| "hwaccel_args"
|
||||
| "input_args"
|
||||
| "output_args.record"
|
||||
| "output_args.detect";
|
||||
|
||||
const getPresetOptions = (
|
||||
data: FfmpegPresetResponse | undefined,
|
||||
field: PresetField | undefined,
|
||||
): string[] => {
|
||||
if (!data || !field) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (field === "hwaccel_args") {
|
||||
return data.hwaccel_args;
|
||||
}
|
||||
|
||||
if (field === "input_args") {
|
||||
return data.input_args;
|
||||
}
|
||||
|
||||
if (field.startsWith("output_args.")) {
|
||||
const key = field.split(".")[1] as "record" | "detect";
|
||||
return data.output_args?.[key] ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const resolveMode = (
|
||||
value: unknown,
|
||||
presets: string[],
|
||||
defaultMode: FfmpegArgsMode,
|
||||
allowInherit: boolean,
|
||||
): FfmpegArgsMode => {
|
||||
if (allowInherit && (value === null || value === undefined)) {
|
||||
return "inherit";
|
||||
}
|
||||
|
||||
if (allowInherit && Array.isArray(value) && value.length === 0) {
|
||||
return "inherit";
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return "manual";
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (presets.length === 0) {
|
||||
return defaultMode;
|
||||
}
|
||||
|
||||
return presets.includes(value) ? "preset" : "manual";
|
||||
}
|
||||
|
||||
return defaultMode;
|
||||
};
|
||||
|
||||
const normalizeManualText = (value: unknown): string => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(" ");
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
export function FfmpegArgsWidget(props: WidgetProps) {
|
||||
const formContext = props.registry?.formContext as
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
|
||||
const isCameraLevel = formContext?.level === "camera";
|
||||
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
|
||||
const { t, i18n } = useTranslation([
|
||||
effectiveNamespace || i18nNamespace || "common",
|
||||
i18nNamespace || "common",
|
||||
"views/settings",
|
||||
]);
|
||||
const {
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
readonly,
|
||||
options,
|
||||
placeholder,
|
||||
schema,
|
||||
id,
|
||||
} = props;
|
||||
const presetField = options?.ffmpegPresetField as PresetField | undefined;
|
||||
const allowInherit = options?.allowInherit === true;
|
||||
const hideDescription = options?.hideDescription === true;
|
||||
const useSplitLayout = options?.splitLayout !== false;
|
||||
|
||||
const { data } = useSWR<FfmpegPresetResponse>("ffmpeg/presets");
|
||||
|
||||
const presetOptions = useMemo(
|
||||
() => getPresetOptions(data, presetField),
|
||||
[data, presetField],
|
||||
);
|
||||
|
||||
const canUsePresets = presetOptions.length > 0;
|
||||
const defaultMode: FfmpegArgsMode = canUsePresets ? "preset" : "manual";
|
||||
|
||||
const detectedMode = useMemo(
|
||||
() => resolveMode(value, presetOptions, defaultMode, allowInherit),
|
||||
[value, presetOptions, defaultMode, allowInherit],
|
||||
);
|
||||
|
||||
const [mode, setMode] = useState<FfmpegArgsMode>(detectedMode);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canUsePresets && detectedMode === "preset") {
|
||||
setMode("manual");
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(detectedMode);
|
||||
}, [canUsePresets, detectedMode]);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(nextMode: FfmpegArgsMode) => {
|
||||
setMode(nextMode);
|
||||
|
||||
if (nextMode === "inherit") {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextMode === "preset") {
|
||||
const currentValue = typeof value === "string" ? value : undefined;
|
||||
const presetValue =
|
||||
currentValue && presetOptions.includes(currentValue)
|
||||
? currentValue
|
||||
: presetOptions[0];
|
||||
if (presetValue) {
|
||||
onChange(presetValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "preset") {
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
|
||||
const manualText = normalizeManualText(value);
|
||||
onChange(manualText);
|
||||
},
|
||||
[mode, onChange, presetOptions, value],
|
||||
);
|
||||
|
||||
const handlePresetChange = useCallback(
|
||||
(preset: string) => {
|
||||
onChange(preset);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleManualChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newText = event.target.value;
|
||||
onChange(newText);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const manualValue = normalizeManualText(value);
|
||||
const presetValue =
|
||||
typeof value === "string" && presetOptions.includes(value) ? value : "";
|
||||
const fallbackDescriptionKey = useMemo(() => {
|
||||
if (!presetField) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isInputScoped = id.includes("_inputs_");
|
||||
const prefix = isInputScoped ? "ffmpeg.inputs" : "ffmpeg";
|
||||
|
||||
if (presetField === "hwaccel_args") {
|
||||
return `${prefix}.hwaccel_args.description`;
|
||||
}
|
||||
|
||||
if (presetField === "input_args") {
|
||||
return `${prefix}.input_args.description`;
|
||||
}
|
||||
|
||||
if (presetField === "output_args.record") {
|
||||
return isInputScoped
|
||||
? "ffmpeg.inputs.output_args.record.description"
|
||||
: "ffmpeg.output_args.record.description";
|
||||
}
|
||||
|
||||
if (presetField === "output_args.detect") {
|
||||
return isInputScoped
|
||||
? "ffmpeg.inputs.output_args.detect.description"
|
||||
: "ffmpeg.output_args.detect.description";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [id, presetField]);
|
||||
|
||||
const translatedDescription =
|
||||
fallbackDescriptionKey &&
|
||||
effectiveNamespace &&
|
||||
i18n.exists(fallbackDescriptionKey, { ns: effectiveNamespace })
|
||||
? t(fallbackDescriptionKey, { ns: effectiveNamespace })
|
||||
: "";
|
||||
const fieldDescription =
|
||||
typeof schema.description === "string" && schema.description.length > 0
|
||||
? schema.description
|
||||
: translatedDescription;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(next) => handleModeChange(next as FfmpegArgsMode)}
|
||||
className="gap-3"
|
||||
>
|
||||
{allowInherit ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="inherit"
|
||||
id={`${id}-inherit`}
|
||||
disabled={disabled || readonly}
|
||||
className={
|
||||
mode === "inherit"
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`${id}-inherit`} className="cursor-pointer text-sm">
|
||||
{t("configForm.ffmpegArgs.inherit", { ns: "views/settings" })}
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="preset"
|
||||
id={`${id}-preset`}
|
||||
disabled={disabled || readonly || !canUsePresets}
|
||||
className={
|
||||
mode === "preset"
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`${id}-preset`} className="cursor-pointer text-sm">
|
||||
{t("configForm.ffmpegArgs.preset", { ns: "views/settings" })}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="manual"
|
||||
id={`${id}-manual`}
|
||||
disabled={disabled || readonly}
|
||||
className={
|
||||
mode === "manual"
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`${id}-manual`} className="cursor-pointer text-sm">
|
||||
{t("configForm.ffmpegArgs.manual", { ns: "views/settings" })}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{mode === "inherit" ? null : mode === "preset" && canUsePresets ? (
|
||||
<Select
|
||||
value={presetValue}
|
||||
onValueChange={handlePresetChange}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full md:max-w-md">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
placeholder ||
|
||||
schema.title ||
|
||||
t("configForm.ffmpegArgs.selectPreset", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{presetOptions.map((preset) => (
|
||||
<SelectItem key={preset} value={preset}>
|
||||
{preset}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
value={manualValue}
|
||||
onChange={handleManualChange}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={
|
||||
placeholder ||
|
||||
t("configForm.ffmpegArgs.manualPlaceholder", {
|
||||
ns: "views/settings",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hideDescription && !useSplitLayout && fieldDescription ? (
|
||||
<p className="text-xs text-muted-foreground">{fieldDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
const INPUT_ROLES = ["detect", "record", "audio"] as const;
|
||||
|
||||
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()) {
|
||||
return [value.trim()];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function InputRolesWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange } = props;
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
const selectedRoles = useMemo(() => normalizeValue(value), [value]);
|
||||
|
||||
const toggleRole = (role: string, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
if (!selectedRoles.includes(role)) {
|
||||
onChange([...selectedRoles, role]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(selectedRoles.filter((item) => item !== role));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-secondary-highlight bg-background_alt p-2 pr-0 md:max-w-md">
|
||||
<div className="grid gap-2">
|
||||
{INPUT_ROLES.map((role) => {
|
||||
const checked = selectedRoles.includes(role);
|
||||
const label = t(`configForm.inputRoles.options.${role}`, {
|
||||
ns: "views/settings",
|
||||
defaultValue: role,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className="flex items-center justify-between rounded-md px-3 py-0"
|
||||
>
|
||||
<label htmlFor={`${id}-${role}`} className="text-sm">
|
||||
{label}
|
||||
</label>
|
||||
<Switch
|
||||
id={`${id}-${role}`}
|
||||
checked={checked}
|
||||
disabled={disabled || readonly}
|
||||
onCheckedChange={(enabled) => toggleRole(role, !!enabled)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Number Widget - Input with number type
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function NumberWidget(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
schema,
|
||||
options,
|
||||
} = props;
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
onChange(undefined);
|
||||
} else {
|
||||
const num =
|
||||
schema.type === "integer" ? parseInt(val, 10) : parseFloat(val);
|
||||
onChange(isNaN(num) ? undefined : num);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
min={schema.minimum}
|
||||
max={schema.maximum}
|
||||
step={(options.step as number) || (schema.type === "integer" ? 1 : 0.1)}
|
||||
onChange={handleChange}
|
||||
onBlur={(e) => onBlur(id, e.target.value)}
|
||||
onFocus={(e) => onFocus(id, e.target.value)}
|
||||
aria-label={schema.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Object Label Switches Widget - For selecting objects via switches
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { SwitchesWidget } from "./SwitchesWidget";
|
||||
import { FormContext } from "./SwitchesWidget";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { JsonObject } from "@/types/configForm";
|
||||
|
||||
// Collect labelmap values (human-readable labels) from a labelmap object.
|
||||
function collectLabelmapLabels(labelmap: unknown, labels: Set<string>) {
|
||||
if (!labelmap || typeof labelmap !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.values(labelmap as JsonObject).forEach((value) => {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
labels.add(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Read labelmap labels from the global model and detector models.
|
||||
function getLabelmapLabels(context: FormContext): string[] {
|
||||
const labels = new Set<string>();
|
||||
const fullConfig = context.fullConfig as FrigateConfig | undefined;
|
||||
|
||||
if (fullConfig?.model) {
|
||||
collectLabelmapLabels(fullConfig.model.labelmap, labels);
|
||||
}
|
||||
|
||||
if (fullConfig?.detectors) {
|
||||
// detectors is a map of detector configs; each may include a model labelmap.
|
||||
Object.values(fullConfig.detectors).forEach((detector) => {
|
||||
if (detector?.model?.labelmap) {
|
||||
collectLabelmapLabels(detector.model.labelmap, labels);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
// Build the list of labels for switches (labelmap + configured track list).
|
||||
function getObjectLabels(context: FormContext): string[] {
|
||||
const labelmapLabels = getLabelmapLabels(context);
|
||||
let cameraLabels: string[] = [];
|
||||
let globalLabels: string[] = [];
|
||||
|
||||
if (context) {
|
||||
// context.cameraValue and context.globalValue should be the entire objects section
|
||||
if (
|
||||
context.cameraValue &&
|
||||
typeof context.cameraValue === "object" &&
|
||||
!Array.isArray(context.cameraValue)
|
||||
) {
|
||||
const trackValue = (context.cameraValue as JsonObject).track;
|
||||
if (Array.isArray(trackValue)) {
|
||||
cameraLabels = trackValue.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
context.globalValue &&
|
||||
typeof context.globalValue === "object" &&
|
||||
!Array.isArray(context.globalValue)
|
||||
) {
|
||||
const globalTrackValue = (context.globalValue as JsonObject).track;
|
||||
if (Array.isArray(globalTrackValue)) {
|
||||
globalLabels = globalTrackValue.filter(
|
||||
(item): item is string => typeof item === "string",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sourceLabels = cameraLabels.length > 0 ? cameraLabels : globalLabels;
|
||||
const combinedLabels = new Set<string>([...labelmapLabels, ...sourceLabels]);
|
||||
return [...combinedLabels].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",
|
||||
listClassName:
|
||||
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Password Widget - Input with password type
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
export function PasswordWidget(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
schema,
|
||||
options,
|
||||
} = props;
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const fieldClassName = getSizedFieldClassName(options, "sm");
|
||||
|
||||
return (
|
||||
<div className={cn("relative", fieldClassName)}>
|
||||
<Input
|
||||
id={id}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || ""}
|
||||
onChange={(e) =>
|
||||
onChange(e.target.value === "" ? undefined : e.target.value)
|
||||
}
|
||||
onBlur={(e) => onBlur(id, e.target.value)}
|
||||
onFocus={(e) => onFocus(id, e.target.value)}
|
||||
aria-label={schema.title}
|
||||
className="w-full pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{showPassword ? (
|
||||
<LuEyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<LuEye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Range Widget - maps to shadcn/ui Slider
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RangeWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, schema, options } = props;
|
||||
|
||||
const min = schema.minimum ?? 0;
|
||||
const max = schema.maximum ?? 100;
|
||||
const step =
|
||||
(options.step as number) || (schema.type === "integer" ? 1 : 0.1);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Slider
|
||||
id={id}
|
||||
value={[value ?? min]}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled || readonly}
|
||||
onValueChange={(vals) => onChange(vals[0])}
|
||||
className={cn("flex-1", disabled && "opacity-50")}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-muted-foreground">
|
||||
{value ?? min}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Select Widget - maps to shadcn/ui Select
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
export function SelectWidget(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
options,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
placeholder,
|
||||
schema,
|
||||
} = props;
|
||||
|
||||
const { enumOptions = [] } = options;
|
||||
const fieldClassName = getSizedFieldClassName(options, "sm");
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value?.toString() ?? ""}
|
||||
onValueChange={(val) => {
|
||||
// Convert back to original type if needed
|
||||
const enumVal = enumOptions.find(
|
||||
(opt: { value: unknown }) => opt.value?.toString() === val,
|
||||
);
|
||||
onChange(enumVal ? enumVal.value : val);
|
||||
}}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<SelectTrigger id={id} className={fieldClassName}>
|
||||
<SelectValue placeholder={placeholder || schema.title || "Select..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enumOptions.map((option: { value: unknown; label: string }) => (
|
||||
<SelectItem key={String(option.value)} value={String(option.value)}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Switch Widget - maps to shadcn/ui Switch
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export function SwitchWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, label, schema } = props;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
id={id}
|
||||
checked={typeof value === "undefined" ? false : value}
|
||||
disabled={disabled || readonly}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
aria-label={label || schema.title || "Toggle"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// Generic Switches Widget - Reusable component for selecting from any list of entities
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FormContext = Pick<
|
||||
ConfigFormContext,
|
||||
| "cameraValue"
|
||||
| "globalValue"
|
||||
| "fullCameraConfig"
|
||||
| "fullConfig"
|
||||
| "t"
|
||||
| "level"
|
||||
> & {
|
||||
fullCameraConfig?: CameraConfig;
|
||||
fullConfig?: FrigateConfig;
|
||||
};
|
||||
|
||||
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;
|
||||
/** Optional class name for the list container */
|
||||
listClassName?: string;
|
||||
/** Enable search input to filter the list */
|
||||
enableSearch?: boolean;
|
||||
};
|
||||
|
||||
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 listClassName = useMemo(
|
||||
() => props.options?.listClassName as string | undefined,
|
||||
[props.options],
|
||||
);
|
||||
|
||||
const enableSearch = useMemo(
|
||||
() => props.options?.enableSearch as boolean | undefined,
|
||||
[props.options],
|
||||
);
|
||||
|
||||
const selectedEntities = useMemo(() => normalizeValue(value), [value]);
|
||||
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const filteredEntities = useMemo(() => {
|
||||
if (!enableSearch || !searchTerm.trim()) {
|
||||
return availableEntities;
|
||||
}
|
||||
const term = searchTerm.toLowerCase();
|
||||
return availableEntities.filter((entity) => {
|
||||
const displayLabel = getDisplayLabel(entity, context);
|
||||
return displayLabel.toLowerCase().includes(term);
|
||||
});
|
||||
}, [availableEntities, searchTerm, enableSearch, getDisplayLabel, context]);
|
||||
|
||||
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: "{{count}} selected",
|
||||
count: selectedEntities.length,
|
||||
})
|
||||
: `${selectedEntities.length} selected`;
|
||||
|
||||
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 pl-0"
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
{isOpen ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{summary}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="rounded-lg bg-secondary p-2 pr-0 md:max-w-md">
|
||||
{availableEntities.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
) : (
|
||||
<>
|
||||
{enableSearch && (
|
||||
<div className="mr-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t?.("configForm.searchPlaceholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Search...",
|
||||
})}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("grid gap-2", listClassName)}>
|
||||
{filteredEntities.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,74 @@
|
||||
// Tags Widget - For array of strings input
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { LuX } from "react-icons/lu";
|
||||
|
||||
export function TagsWidget(props: WidgetProps) {
|
||||
const { id, value = [], disabled, readonly, onChange, schema } = props;
|
||||
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const tags = useMemo(() => (Array.isArray(value) ? value : []), [value]);
|
||||
|
||||
const addTag = useCallback(() => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
onChange([...tags, trimmed]);
|
||||
setInputValue("");
|
||||
}
|
||||
}, [inputValue, tags, onChange]);
|
||||
|
||||
const removeTag = useCallback(
|
||||
(tagToRemove: string) => {
|
||||
onChange(tags.filter((tag: string) => tag !== tagToRemove));
|
||||
},
|
||||
[tags, onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
} else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) {
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag: string, index: number) => (
|
||||
<Badge key={`${tag}-${index}`} variant="secondary" className="gap-1">
|
||||
{tag}
|
||||
{!disabled && !readonly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 hover:bg-transparent"
|
||||
onClick={() => removeTag(tag)}
|
||||
>
|
||||
<LuX className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
placeholder={`Add ${schema.title?.toLowerCase() || "item"}...`}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={addTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Text Widget - maps to shadcn/ui Input
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
export function TextWidget(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
schema,
|
||||
options,
|
||||
} = props;
|
||||
|
||||
const isNullable = Array.isArray(schema.type)
|
||||
? schema.type.includes("null")
|
||||
: false;
|
||||
const fieldClassName = getSizedFieldClassName(options, "xs");
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || (options.placeholder as string) || ""}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.value === ""
|
||||
? isNullable
|
||||
? null
|
||||
: undefined
|
||||
: e.target.value,
|
||||
)
|
||||
}
|
||||
onBlur={(e) => onBlur(id, e.target.value)}
|
||||
onFocus={(e) => onFocus(id, e.target.value)}
|
||||
aria-label={schema.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Textarea Widget - maps to shadcn/ui Textarea
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
export function TextareaWidget(props: WidgetProps) {
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
readonly,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
placeholder,
|
||||
schema,
|
||||
options,
|
||||
} = props;
|
||||
|
||||
const isNullable = Array.isArray(schema.type)
|
||||
? schema.type.includes("null")
|
||||
: false;
|
||||
const fieldClassName = getSizedFieldClassName(options, "md");
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
className={cn("text-md", fieldClassName)}
|
||||
value={value ?? ""}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || (options.placeholder as string) || ""}
|
||||
rows={(options.rows as number) || 3}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.value === ""
|
||||
? isNullable
|
||||
? null
|
||||
: undefined
|
||||
: e.target.value,
|
||||
)
|
||||
}
|
||||
onBlur={(e) => onBlur(id, e.target.value)}
|
||||
onFocus={(e) => onFocus(id, e.target.value)}
|
||||
aria-label={schema.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from "react";
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
const DEFAULT_TIMEZONE_VALUE = "__browser__";
|
||||
|
||||
function getTimezoneList(): string[] {
|
||||
if (typeof Intl !== "undefined") {
|
||||
const intl = Intl as typeof Intl & {
|
||||
supportedValuesOf?: (key: string) => string[];
|
||||
};
|
||||
const supported = intl.supportedValuesOf?.("timeZone");
|
||||
if (supported && supported.length > 0) {
|
||||
return [...supported].sort();
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return fallback ? [fallback] : ["UTC"];
|
||||
}
|
||||
|
||||
export function TimezoneSelectWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, schema, options } = props;
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
|
||||
const timezones = useMemo(() => getTimezoneList(), []);
|
||||
const selectedValue = value ? String(value) : DEFAULT_TIMEZONE_VALUE;
|
||||
const fieldClassName = getSizedFieldClassName(options, "sm");
|
||||
const defaultLabel = t("configForm.timezone.defaultOption", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onValueChange={(next) =>
|
||||
onChange(next === DEFAULT_TIMEZONE_VALUE ? null : next)
|
||||
}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<SelectTrigger id={id} className={fieldClassName}>
|
||||
<SelectValue placeholder={schema.title || defaultLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_TIMEZONE_VALUE}>{defaultLabel}</SelectItem>
|
||||
{timezones.map((timezone) => (
|
||||
<SelectItem key={timezone} value={timezone}>
|
||||
{timezone}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimezoneSelectWidget;
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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",
|
||||
listClassName: "max-h-64 overflow-y-auto scrollbar-container",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user