add profile state management and save preview support

This commit is contained in:
Josh Hawkins 2026-03-09 15:06:11 -05:00
parent 72b4a4ddad
commit edf7fcb5b4
3 changed files with 134 additions and 7 deletions

View File

@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
export type SaveAllPreviewItem = {
scope: "global" | "camera";
cameraName?: string;
profileName?: string;
fieldPath: string;
value: unknown;
};
@ -114,6 +115,18 @@ export default function SaveAllPreviewPopover({
})}
</span>
<span className="truncate">{scopeLabel}</span>
{item.profileName && (
<>
<span className="text-muted-foreground">
{t("saveAllPreview.profile.label", {
ns: "views/settings",
})}
</span>
<span className="truncate font-medium">
{item.profileName}
</span>
</>
)}
<span className="text-muted-foreground">
{t("saveAllPreview.field.label", {
ns: "views/settings",

View File

@ -87,8 +87,10 @@ import { mutate } from "swr";
import { RJSFSchema } from "@rjsf/utils";
import {
buildConfigDataForPath,
parseProfileFromSectionPath,
prepareSectionSavePayload,
} from "@/utils/configUtil";
import type { ProfileState } from "@/types/profile";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import SaveAllPreviewPopover, {
@ -621,6 +623,22 @@ export default function Settings() {
Record<string, unknown>
>({});
// Profile editing state
const [editingProfile, setEditingProfile] = useState<
Record<string, string | null>
>({});
const [newProfiles, setNewProfiles] = useState<string[]>([]);
const allProfileNames = useMemo(() => {
if (!config) return [];
const names = new Set<string>();
Object.values(config.cameras).forEach((cam) => {
Object.keys(cam.profiles ?? {}).forEach((p) => names.add(p));
});
newProfiles.forEach((p) => names.add(p));
return [...names].sort();
}, [config, newProfiles]);
const navigate = useNavigate();
const cameras = useMemo(() => {
@ -692,11 +710,20 @@ export default function Settings() {
const { scope, cameraName, sectionPath } =
parsePendingDataKey(pendingDataKey);
const { isProfile, profileName, actualSection } =
parseProfileFromSectionPath(sectionPath);
const flattened = flattenOverrides(payload.sanitizedOverrides);
const displaySection = isProfile ? actualSection : sectionPath;
flattened.forEach(({ path, value }) => {
const fieldPath = path ? `${sectionPath}.${path}` : sectionPath;
items.push({ scope, cameraName, fieldPath, value });
const fieldPath = path ? `${displaySection}.${path}` : displaySection;
items.push({
scope,
cameraName,
profileName: isProfile ? profileName : undefined,
fieldPath,
value,
});
});
},
);
@ -726,15 +753,20 @@ export default function Settings() {
level = "global";
}
// For profile keys like "profiles.armed.detect", extract the actual section
const { actualSection } = parseProfileFromSectionPath(sectionPath);
if (level === "camera") {
return CAMERA_SECTION_MAPPING[sectionPath] as SettingsType | undefined;
return CAMERA_SECTION_MAPPING[actualSection] as
| SettingsType
| undefined;
}
return (
(GLOBAL_SECTION_MAPPING[sectionPath] as SettingsType | undefined) ??
(ENRICHMENTS_SECTION_MAPPING[sectionPath] as
(GLOBAL_SECTION_MAPPING[actualSection] as SettingsType | undefined) ??
(ENRICHMENTS_SECTION_MAPPING[actualSection] as
| SettingsType
| undefined) ??
(SYSTEM_SECTION_MAPPING[sectionPath] as SettingsType | undefined)
(SYSTEM_SECTION_MAPPING[actualSection] as SettingsType | undefined)
);
},
[],
@ -884,6 +916,16 @@ export default function Settings() {
setPendingDataBySection({});
setUnsavedChanges(false);
setEditingProfile({});
// Clear new profiles that don't exist in saved config
if (config) {
const savedNames = new Set<string>();
Object.values(config.cameras).forEach((cam) => {
Object.keys(cam.profiles ?? {}).forEach((p) => savedNames.add(p));
});
setNewProfiles((prev) => prev.filter((p) => savedNames.has(p)));
}
setSectionStatusByKey((prev) => {
const updated = { ...prev };
@ -899,7 +941,7 @@ export default function Settings() {
}
return updated;
});
}, [pendingDataBySection, pendingKeyToMenuKey]);
}, [pendingDataBySection, pendingKeyToMenuKey, config]);
const handleDialog = useCallback(
(save: boolean) => {
@ -970,6 +1012,75 @@ export default function Settings() {
}
}, [t, contentMobileOpen]);
// Profile state handlers
const handleSelectProfile = useCallback(
(camera: string, section: string, profile: string | null) => {
const key = `${camera}::${section}`;
setEditingProfile((prev) => {
if (profile === null) {
const { [key]: _, ...rest } = prev;
return rest;
}
return { ...prev, [key]: profile };
});
},
[],
);
const handleAddProfile = useCallback((name: string) => {
setNewProfiles((prev) => (prev.includes(name) ? prev : [...prev, name]));
}, []);
const handleDeleteProfileSection = useCallback(
async (camera: string, section: string, profile: string) => {
try {
await axios.put("config/set", {
config_data: {
cameras: {
[camera]: {
profiles: {
[profile]: {
[section]: "",
},
},
},
},
},
});
await mutate("config");
// Switch back to base config
handleSelectProfile(camera, section, null);
toast.success(
t("toast.save.success", {
ns: "common",
}),
);
} catch {
toast.error(t("toast.save.error.title", { ns: "common" }));
}
},
[handleSelectProfile, t],
);
const profileState: ProfileState = useMemo(
() => ({
editingProfile,
newProfiles,
allProfileNames,
onSelectProfile: handleSelectProfile,
onAddProfile: handleAddProfile,
onDeleteProfileSection: handleDeleteProfileSection,
}),
[
editingProfile,
newProfiles,
allProfileNames,
handleSelectProfile,
handleAddProfile,
handleDeleteProfileSection,
],
);
const handleSectionStatusChange = useCallback(
(sectionKey: string, level: "global" | "camera", status: SectionStatus) => {
// Map section keys to menu keys based on level
@ -1244,6 +1355,7 @@ export default function Settings() {
onSectionStatusChange={handleSectionStatusChange}
pendingDataBySection={pendingDataBySection}
onPendingDataChange={handlePendingDataChange}
profileState={profileState}
/>
);
})()}

View File

@ -5,6 +5,7 @@ import { ConfigSectionTemplate } from "@/components/config-form/sections";
import type { PolygonType } from "@/types/canvas";
import { Badge } from "@/components/ui/badge";
import type { ConfigSectionData } from "@/types/configForm";
import type { ProfileState } from "@/types/profile";
import { getSectionConfig } from "@/utils/configUtil";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom";
@ -26,6 +27,7 @@ export type SettingsPageProps = {
cameraName: string | undefined,
data: ConfigSectionData | null,
) => void;
profileState?: ProfileState;
};
export type SectionStatus = {