fix sections and live validation

This commit is contained in:
Josh Hawkins 2026-01-25 17:29:52 -06:00
parent b1cd5432bf
commit 55c6c50c97
11 changed files with 128 additions and 17 deletions

View File

@ -1,6 +1,10 @@
{
"label": "Global Audio events configuration",
"description": "Global settings for audio-based event detection; camera-level settings can override these.",
"groups": {
"detection": "Detection",
"sensitivity": "Sensitivity"
},
"enabled": {
"label": "Enable audio events",
"description": "Enable or disable audio event detection globally. Can be overridden per camera."

View File

@ -1,6 +1,10 @@
{
"label": "Object tracking",
"description": "Settings for the detection/detect role used to run object detection and initialize trackers.",
"groups": {
"resolution": "Resolution",
"tracking": "Tracking"
},
"enabled": {
"label": "Detection Enabled",
"description": "Enable or disable object detection for this camera. Detection must be enabled for object tracking to run."

View File

@ -1,6 +1,10 @@
{
"label": "Global motion detection configuration",
"description": "Default motion detection settings applied to cameras unless overridden per-camera.",
"groups": {
"sensitivity": "Sensitivity",
"algorithm": "Algorithm"
},
"enabled": {
"label": "Enable motion detection",
"description": "Enable or disable motion detection globally; per-camera settings can override this."

View File

@ -1,6 +1,10 @@
{
"label": "Global object configuration",
"description": "Global object tracking defaults including which labels to track and per-object filters.",
"groups": {
"tracking": "Tracking",
"filtering": "Filtering"
},
"track": {
"label": "Objects to track",
"description": "List of object labels to track globally; camera configs can override this."

View File

@ -1,6 +1,10 @@
{
"label": "Global record configuration",
"description": "Global recording and retention settings applied to cameras unless overridden per-camera.",
"groups": {
"retention": "Retention",
"events": "Events"
},
"enabled": {
"label": "Enable record on all cameras",
"description": "Enable or disable recording globally; individual cameras can override this."

View File

@ -1,6 +1,9 @@
{
"label": "Global snapshots configuration",
"description": "Global settings for saved JPEG snapshots of tracked objects; can be overridden per-camera.",
"groups": {
"display": "Display"
},
"enabled": {
"label": "Snapshots enabled",
"description": "Enable or disable saving snapshots globally."

View File

@ -1,6 +1,9 @@
{
"label": "Global timestamp style configuration",
"description": "Global styling options for in-feed timestamps applied to recordings and snapshots.",
"groups": {
"appearance": "Appearance"
},
"position": {
"label": "Timestamp position",
"description": "Position of the timestamp on the image (tl/tr/bl/br)."

View File

@ -27,6 +27,8 @@ export interface ConfigFormProps {
uiSchema?: UiSchema;
/** Field ordering */
fieldOrder?: string[];
/** Field groups for layout */
fieldGroups?: Record<string, string[]>;
/** Fields to hide */
hiddenFields?: string[];
/** Fields marked as advanced (collapsed by default) */
@ -55,6 +57,7 @@ export function ConfigForm({
onError,
uiSchema: customUiSchema,
fieldOrder,
fieldGroups,
hiddenFields,
advancedFields,
disabled = false,
@ -100,12 +103,13 @@ export function ConfigForm({
const finalUiSchema = useMemo(
() => ({
...generatedUiSchema,
"ui:groups": fieldGroups,
...customUiSchema,
"ui:submitButtonOptions": showSubmit
? { norender: false }
: { norender: true },
}),
[generatedUiSchema, customUiSchema, showSubmit],
[generatedUiSchema, customUiSchema, showSubmit, fieldGroups],
);
// Create error transformer for user-friendly error messages

View File

@ -45,6 +45,8 @@ export interface SectionConfig {
advancedFields?: string[];
/** Fields to compare for override detection */
overrideFields?: string[];
/** Whether to enable live validation */
liveValidate?: boolean;
/** Additional uiSchema overrides */
uiSchema?: UiSchema;
}
@ -466,8 +468,10 @@ export function createConfigSection({
formData={pendingData || formData}
onChange={handleChange}
fieldOrder={sectionConfig.fieldOrder}
fieldGroups={sectionConfig.fieldGroups}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate}
uiSchema={sectionConfig.uiSchema}
disabled={disabled || isSaving}
readonly={readonly}

View File

@ -9,31 +9,111 @@ import {
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
const { title, description, properties } = props;
const { title, description, properties, uiSchema } = props;
const formContext = (props as Record<string, unknown>).formContext as
| Record<string, unknown>
| undefined;
// Check if this is a root-level object
const isRoot = !title;
const [isOpen, setIsOpen] = useState(true);
const { t } = useTranslation([
(formContext?.i18nNamespace as string | undefined) || "common",
]);
const groupDefinitions =
(uiSchema?.["ui:groups"] as Record<string, string[]> | undefined) || {};
const isHiddenProp = (prop: (typeof properties)[number]) =>
prop.content.props.uiSchema?.["ui:widget"] === "hidden";
const visibleProps = properties.filter((prop) => !isHiddenProp(prop));
// Check for advanced section grouping
const advancedProps = properties.filter(
const advancedProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced === true,
);
const regularProps = properties.filter(
const regularProps = visibleProps.filter(
(p) => p.content.props.uiSchema?.["ui:options"]?.advanced !== true,
);
const [showAdvanced, setShowAdvanced] = useState(false);
const toTitle = (value: string) =>
value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
const renderGroupedFields = (items: (typeof properties)[number][]) => {
if (!items.length) {
return null;
}
const grouped = new Set<string>();
const groups = Object.entries(groupDefinitions)
.map(([groupKey, fields]) => {
const ordered = fields
.map((field) => items.find((item) => item.name === field))
.filter(Boolean) as (typeof properties)[number][];
if (ordered.length === 0) {
return null;
}
ordered.forEach((item) => grouped.add(item.name));
const label = t(`groups.${groupKey}`, {
defaultValue: toTitle(groupKey),
});
return {
key: groupKey,
label,
items: ordered,
};
})
.filter(Boolean) as Array<{
key: string;
label: string;
items: (typeof properties)[number][];
}>;
const ungrouped = items.filter((item) => !grouped.has(item.name));
return (
<div className="space-y-6">
{groups.map((group) => (
<div key={group.key} className="space-y-4">
<div className="text-sm font-medium text-muted-foreground">
{group.label}
</div>
<div className="space-y-4">
{group.items.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</div>
</div>
))}
{ungrouped.length > 0 && (
<div className={cn("space-y-4", groups.length > 0 && "pt-2")}>
{ungrouped.map((element) => (
<div key={element.name}>{element.content}</div>
))}
</div>
)}
</div>
);
};
// Root level renders children directly
if (isRoot) {
return (
<div className="space-y-6">
{regularProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
{renderGroupedFields(regularProps)}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
@ -48,9 +128,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{advancedProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
{renderGroupedFields(advancedProps)}
</CollapsibleContent>
</Collapsible>
)}
@ -83,9 +161,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{regularProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
{renderGroupedFields(regularProps)}
{advancedProps.length > 0 && (
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
@ -104,9 +180,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{advancedProps.map((element) => (
<div key={element.name}>{element.content}</div>
))}
{renderGroupedFields(advancedProps)}
</CollapsibleContent>
</Collapsible>
)}

View File

@ -66,6 +66,7 @@ const globalSectionConfigs: Record<
fieldOrder?: string[];
hiddenFields?: string[];
advancedFields?: string[];
liveValidate?: boolean;
i18nNamespace: string;
}
> = {
@ -114,8 +115,8 @@ const globalSectionConfigs: Record<
"trusted_proxies",
"hash_iterations",
"roles",
"admin_first_time_login",
],
hiddenFields: ["admin_first_time_login"],
advancedFields: [
"cookie_name",
"cookie_secure",
@ -148,6 +149,7 @@ const globalSectionConfigs: Record<
"separator",
],
advancedFields: ["header_map", "auth_secret", "separator"],
liveValidate: true,
},
ui: {
i18nNamespace: "config/ui",
@ -502,6 +504,7 @@ const GlobalConfigSection = memo(function GlobalConfigSection({
fieldOrder={sectionConfig.fieldOrder}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate}
showSubmit={false}
i18nNamespace={sectionConfig.i18nNamespace}
disabled={isSaving}