mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-28 06:39: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,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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user