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:
Josh Hawkins
2026-02-27 08:55:36 -07:00
committed by GitHub
parent eeefbf2bb5
commit e7250f24cb
206 changed files with 22200 additions and 4435 deletions
@@ -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
);
}