combine review sections

This commit is contained in:
Josh Hawkins 2026-02-03 09:21:47 -06:00
parent f82a721477
commit bbd62129ca
4 changed files with 91 additions and 26 deletions

View File

@ -1,9 +1,15 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import SemanticSearchReindex from "./SemanticSearchReindex.tsx"; import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView.tsx";
export type RendererComponent = ComponentType< // Props that will be injected into all section renderers
Record<string, unknown> | undefined export type SectionRendererProps = {
>; selectedCamera?: string;
setUnsavedChanges?: (hasChanges: boolean) => void;
[key: string]: unknown; // Allow additional props from uiSchema
};
export type RendererComponent = ComponentType<SectionRendererProps>;
export type SectionRenderers = Record< export type SectionRenderers = Record<
string, string,
@ -16,10 +22,26 @@ export type SectionRenderers = Record<
// names to React components. These names are referenced from `uiSchema` // names to React components. These names are referenced from `uiSchema`
// descriptors (e.g., `{ "ui:after": { render: "SemanticSearchReindex" } }`) and // descriptors (e.g., `{ "ui:after": { render: "SemanticSearchReindex" } }`) and
// are resolved by `FieldTemplate` through `formContext.renderers`. // are resolved by `FieldTemplate` through `formContext.renderers`.
//
// RUNTIME PROPS INJECTION:
// All renderers automatically receive the following props from BaseSection:
// - selectedCamera?: string - The current camera name (camera-level only)
// - setUnsavedChanges?: (hasChanges: boolean) => void - Callback to signal unsaved state
//
// Additional static props can be passed via uiSchema:
// { "ui:after": { render: "MyRenderer", props: { customProp: "value" } } }
//
// ADDING NEW RENDERERS:
// 1. Create your component accepting SectionRendererProps
// 2. Import and add it to the appropriate section in this registry
// 3. Reference it in your section's uiSchema using the { render: "ComponentName" } syntax
export const sectionRenderers: SectionRenderers = { export const sectionRenderers: SectionRenderers = {
semantic_search: { semantic_search: {
SemanticSearchReindex, SemanticSearchReindex,
}, },
review: {
CameraReviewSettingsView,
},
}; };
export default sectionRenderers; export default sectionRenderers;

View File

@ -53,6 +53,7 @@ import {
import { applySchemaDefaults } from "@/lib/config-schema"; import { applySchemaDefaults } from "@/lib/config-schema";
import { isJsonObject } from "@/lib/utils"; import { isJsonObject } from "@/lib/utils";
import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm"; import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm";
import ActivityIndicator from "@/components/indicators/activity-indicator";
export interface SectionConfig { export interface SectionConfig {
/** Field ordering within the section */ /** Field ordering within the section */
@ -513,7 +514,7 @@ export function ConfigSection({
const needsRestart = requiresRestartForOverrides(overrides); const needsRestart = requiresRestartForOverrides(overrides);
await axios.put("config/sett", { await axios.put("config/set", {
requires_restart: needsRestart ? 1 : 0, requires_restart: needsRestart ? 1 : 0,
update_topic: updateTopic, update_topic: updateTopic,
config_data: { config_data: {
@ -607,7 +608,7 @@ export function ConfigSection({
const configData = level === "global" ? effectiveSchemaDefaults : ""; const configData = level === "global" ? effectiveSchemaDefaults : "";
await axios.put("config/sett", { await axios.put("config/set", {
requires_restart: requiresRestart ? 0 : 1, requires_restart: requiresRestart ? 0 : 1,
update_topic: updateTopic, update_topic: updateTopic,
config_data: { config_data: {
@ -688,6 +689,42 @@ export function ConfigSection({
); );
}, [sectionConfig.customValidate, sectionValidation]); }, [sectionConfig.customValidate, sectionValidation]);
// Wrap renderers with runtime props (selectedCamera, setUnsavedChanges, etc.)
const wrappedRenderers = useMemo(() => {
const baseRenderers =
sectionConfig?.renderers ?? sectionRenderers?.[sectionPath];
if (!baseRenderers) return undefined;
// Create wrapper that injects runtime props
return Object.fromEntries(
Object.entries(baseRenderers).map(([key, RendererComponent]) => [
key,
(staticProps: Record<string, unknown> = {}) => (
<RendererComponent
{...staticProps}
selectedCamera={cameraName}
setUnsavedChanges={(hasChanges: boolean) => {
// Translate setUnsavedChanges to pending data state
if (hasChanges && !pendingData) {
// Component signaled changes but we don't have pending data yet
// This can happen when the component manages its own state
} else if (!hasChanges && pendingData) {
// Component signaled no changes, clear pending
setPendingData(null);
}
}}
/>
),
]),
);
}, [
sectionConfig?.renderers,
sectionPath,
cameraName,
pendingData,
setPendingData,
]);
if (!modifiedSchema) { if (!modifiedSchema) {
return null; return null;
} }
@ -749,8 +786,7 @@ export function ConfigSection({
// section prefix to templates so they can attempt `${section}.${field}` lookups. // section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionI18nPrefix: sectionPath, sectionI18nPrefix: sectionPath,
t, t,
renderers: renderers: wrappedRenderers,
sectionConfig?.renderers ?? sectionRenderers?.[sectionPath],
sectionDocs: sectionConfig.sectionDocs, sectionDocs: sectionConfig.sectionDocs,
fieldDocs: sectionConfig.fieldDocs, fieldDocs: sectionConfig.fieldDocs,
}} }}
@ -775,7 +811,7 @@ export function ConfigSection({
onClick={() => setIsResetDialogOpen(true)} onClick={() => setIsResetDialogOpen(true)}
variant="outline" variant="outline"
disabled={isSaving || disabled} disabled={isSaving || disabled}
className="gap-2" className="flex flex-1 gap-2"
> >
<LuRotateCcw className="h-4 w-4" /> <LuRotateCcw className="h-4 w-4" />
{level === "global" {level === "global"
@ -794,7 +830,7 @@ export function ConfigSection({
onClick={handleReset} onClick={handleReset}
variant="outline" variant="outline"
disabled={isSaving || disabled} disabled={isSaving || disabled}
className="gap-2" className="flex min-w-36 flex-1 gap-2"
> >
{t("undo", { ns: "common", defaultValue: "Undo" })} {t("undo", { ns: "common", defaultValue: "Undo" })}
</Button> </Button>
@ -803,12 +839,19 @@ export function ConfigSection({
onClick={handleSave} onClick={handleSave}
variant="select" variant="select"
disabled={!hasChanges || isSaving || disabled} disabled={!hasChanges || isSaving || disabled}
className="gap-2" className="flex min-w-36 flex-1 gap-2"
> >
<LuSave className="h-4 w-4" /> {isSaving ? (
{isSaving <>
? t("saving", { ns: "common", defaultValue: "Saving..." }) <ActivityIndicator className="h-4 w-4" />
: t("save", { ns: "common", defaultValue: "Save" })} {t("saving", { ns: "common", defaultValue: "Saving..." })}
</>
) : (
<>
<LuSave className="h-4 w-4" />
{t("save", { ns: "common", defaultValue: "Save" })}
</>
)}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -33,7 +33,6 @@ import useSWR from "swr";
import FilterSwitch from "@/components/filter/FilterSwitch"; import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas"; import { PolygonType } from "@/types/canvas";
import CameraReviewSettingsView from "@/views/settings/CameraReviewSettingsView";
import CameraManagementView from "@/views/settings/CameraManagementView"; import CameraManagementView from "@/views/settings/CameraManagementView";
import MotionTunerView from "@/views/settings/MotionTunerView"; import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
@ -80,6 +79,7 @@ import {
MobilePageHeader, MobilePageHeader,
MobilePageTitle, MobilePageTitle,
} from "@/components/mobile/MobilePage"; } from "@/components/mobile/MobilePage";
import { Toaster } from "@/components/ui/sonner";
const allSettingsViews = [ const allSettingsViews = [
"profileSettings", "profileSettings",
@ -119,7 +119,7 @@ const allSettingsViews = [
"cameraSnapshots", "cameraSnapshots",
"cameraMotion", "cameraMotion",
"cameraObjects", "cameraObjects",
"cameraConfigReview", "cameraReview",
"cameraAudioEvents", "cameraAudioEvents",
"cameraAudioTranscription", "cameraAudioTranscription",
"cameraNotifications", "cameraNotifications",
@ -132,7 +132,6 @@ const allSettingsViews = [
"cameraUi", "cameraUi",
"cameraTimestampStyle", "cameraTimestampStyle",
"cameraManagement", "cameraManagement",
"cameraReview",
"masksAndZones", "masksAndZones",
"motionTuner", "motionTuner",
"enrichments", "enrichments",
@ -224,7 +223,7 @@ const CameraRecordingSettingsPage = createSectionPage("record", "camera");
const CameraSnapshotsSettingsPage = createSectionPage("snapshots", "camera"); const CameraSnapshotsSettingsPage = createSectionPage("snapshots", "camera");
const CameraMotionSettingsPage = createSectionPage("motion", "camera"); const CameraMotionSettingsPage = createSectionPage("motion", "camera");
const CameraObjectsSettingsPage = createSectionPage("objects", "camera"); const CameraObjectsSettingsPage = createSectionPage("objects", "camera");
const CameraConfigReviewSettingsPage = createSectionPage("review", "camera"); const CameraReviewSettingsPage = createSectionPage("review", "camera");
const CameraAudioEventsSettingsPage = createSectionPage("audio", "camera"); const CameraAudioEventsSettingsPage = createSectionPage("audio", "camera");
const CameraAudioTranscriptionSettingsPage = createSectionPage( const CameraAudioTranscriptionSettingsPage = createSectionPage(
"audio_transcription", "audio_transcription",
@ -290,7 +289,7 @@ const settingsGroups = [
{ key: "cameraSnapshots", component: CameraSnapshotsSettingsPage }, { key: "cameraSnapshots", component: CameraSnapshotsSettingsPage },
{ key: "cameraMotion", component: CameraMotionSettingsPage }, { key: "cameraMotion", component: CameraMotionSettingsPage },
{ key: "cameraObjects", component: CameraObjectsSettingsPage }, { key: "cameraObjects", component: CameraObjectsSettingsPage },
{ key: "cameraConfigReview", component: CameraConfigReviewSettingsPage }, { key: "cameraReview", component: CameraReviewSettingsPage },
{ key: "cameraAudioEvents", component: CameraAudioEventsSettingsPage }, { key: "cameraAudioEvents", component: CameraAudioEventsSettingsPage },
{ {
key: "cameraAudioTranscription", key: "cameraAudioTranscription",
@ -318,7 +317,6 @@ const settingsGroups = [
component: CameraTimestampStyleSettingsPage, component: CameraTimestampStyleSettingsPage,
}, },
{ key: "cameraManagement", component: CameraManagementView }, { key: "cameraManagement", component: CameraManagementView },
{ key: "cameraReview", component: CameraReviewSettingsView },
{ key: "masksAndZones", component: MasksAndZonesView }, { key: "masksAndZones", component: MasksAndZonesView },
{ key: "motionTuner", component: MotionTunerView }, { key: "motionTuner", component: MotionTunerView },
], ],
@ -412,7 +410,7 @@ const CAMERA_SELECT_BUTTON_PAGES = [
"cameraSnapshots", "cameraSnapshots",
"cameraMotion", "cameraMotion",
"cameraObjects", "cameraObjects",
"cameraConfigReview", "cameraReview",
"cameraAudioEvents", "cameraAudioEvents",
"cameraAudioTranscription", "cameraAudioTranscription",
"cameraNotifications", "cameraNotifications",
@ -424,7 +422,6 @@ const CAMERA_SELECT_BUTTON_PAGES = [
"cameraOnvif", "cameraOnvif",
"cameraUi", "cameraUi",
"cameraTimestampStyle", "cameraTimestampStyle",
"cameraReview",
"masksAndZones", "masksAndZones",
"motionTuner", "motionTuner",
"triggers", "triggers",
@ -440,7 +437,7 @@ const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
snapshots: "cameraSnapshots", snapshots: "cameraSnapshots",
motion: "cameraMotion", motion: "cameraMotion",
objects: "cameraObjects", objects: "cameraObjects",
review: "cameraConfigReview", review: "cameraReview",
audio: "cameraAudioEvents", audio: "cameraAudioEvents",
audio_transcription: "cameraAudioTranscription", audio_transcription: "cameraAudioTranscription",
notifications: "cameraNotifications", notifications: "cameraNotifications",
@ -788,6 +785,7 @@ export default function Settings() {
if (isMobile) { if (isMobile) {
return ( return (
<> <>
<Toaster position="top-center" />
{!contentMobileOpen && ( {!contentMobileOpen && (
<div className="flex size-full flex-col"> <div className="flex size-full flex-col">
<div className="sticky -top-2 z-50 mb-2 bg-background p-4"> <div className="sticky -top-2 z-50 mb-2 bg-background p-4">
@ -925,7 +923,8 @@ export default function Settings() {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex min-h-16 items-center justify-between border-b border-secondary p-3"> <Toaster position="top-center" />
<div className="flex items-center justify-between border-b border-secondary p-3">
<Heading as="h3" className="mb-0"> <Heading as="h3" className="mb-0">
{t("menu.settings", { ns: "common" })} {t("menu.settings", { ns: "common" })}
</Heading> </Heading>

View File

@ -43,7 +43,7 @@ import { formatList } from "@/utils/stringUtil";
type CameraReviewSettingsViewProps = { type CameraReviewSettingsViewProps = {
selectedCamera: string; selectedCamera: string;
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>; setUnsavedChanges?: (hasChanges: boolean) => void;
}; };
type CameraReviewSettingsValueType = { type CameraReviewSettingsValueType = {
@ -249,7 +249,7 @@ export default function CameraReviewSettingsView({
} }
setChangedValue(false); setChangedValue(false);
setUnsavedChanges(false); setUnsavedChanges?.(false);
removeMessage( removeMessage(
"camera_settings", "camera_settings",
`review_classification_settings_${selectedCamera}`, `review_classification_settings_${selectedCamera}`,
@ -746,6 +746,7 @@ export default function CameraReviewSettingsView({
</Form> </Form>
</div> </div>
</div> </div>
<Separator className="my-2 flex bg-secondary" />
</> </>
); );
} }