immediately create profiles on backend instead of deferring to Save All

This commit is contained in:
Josh Hawkins 2026-03-12 10:53:53 -05:00
parent e92fa2b4ba
commit 611316906a
4 changed files with 42 additions and 112 deletions

View File

@ -1480,6 +1480,7 @@
"deleteProfile": "Delete Profile",
"deleteProfileConfirm": "Delete profile \"{{profile}}\" from all cameras? This cannot be undone.",
"deleteSuccess": "Profile '{{profile}}' deleted",
"createSuccess": "Profile '{{profile}}' created",
"removeOverride": "Remove Profile Override",
"deleteSection": "Delete Section Overrides",
"deleteSectionConfirm": "Remove the {{section}} overrides for profile {{profile}} on {{camera}}?",

View File

@ -660,32 +660,20 @@ export default function Settings() {
const [editingProfile, setEditingProfile] = useState<
Record<string, string | null>
>({});
const [newProfiles, setNewProfiles] = useState<string[]>([]);
const [profilesUIEnabled, setProfilesUIEnabled] = useState(false);
const allProfileNames = useMemo(() => {
const names = new Set<string>();
if (config?.profiles) {
Object.keys(config.profiles).forEach((p) => names.add(p));
}
newProfiles.forEach((p) => names.add(p));
return [...names].sort();
}, [config, newProfiles]);
if (!config?.profiles) return [];
return Object.keys(config.profiles).sort();
}, [config]);
const profileFriendlyNames = useMemo(() => {
const map = new Map<string, string>();
if (profilesData?.profiles) {
profilesData.profiles.forEach((p) => map.set(p.name, p.friendly_name));
}
// Include pending (unsaved) profile definitions
for (const [key, data] of Object.entries(pendingDataBySection)) {
if (key.startsWith("__profile_def__.") && data?.friendly_name) {
const id = key.slice("__profile_def__.".length);
map.set(id, String(data.friendly_name));
}
}
return map;
}, [profilesData, pendingDataBySection]);
}, [profilesData]);
const navigate = useNavigate();
@ -842,27 +830,6 @@ export default function Settings() {
for (const key of pendingKeys) {
const pendingData = pendingDataBySection[key];
// Handle top-level profile definition saves
if (key.startsWith("__profile_def__.")) {
const profileId = key.replace("__profile_def__.", "");
try {
const configData = { profiles: { [profileId]: pendingData } };
await axios.put("config/set", {
requires_restart: 0,
config_data: configData,
});
setPendingDataBySection((prev) => {
const { [key]: _, ...rest } = prev;
return rest;
});
savedKeys.push(key);
successCount++;
} catch {
failCount++;
}
continue;
}
try {
const payload = prepareSectionSavePayload({
pendingDataKey: key,
@ -912,11 +879,6 @@ export default function Settings() {
// Refresh config from server once
await mutate("config");
// If any profile definitions were saved, refresh profiles data too
if (savedKeys.some((key) => key.startsWith("__profile_def__."))) {
await mutate("profiles");
}
// Clear hasChanges in sidebar for all successfully saved sections
if (savedKeys.length > 0) {
setSectionStatusByKey((prev) => {
@ -995,12 +957,6 @@ export default function Settings() {
setUnsavedChanges(false);
setEditingProfile({});
// Clear new profiles that now exist in top-level config
if (config) {
const savedNames = new Set<string>(Object.keys(config.profiles ?? {}));
setNewProfiles((prev) => prev.filter((p) => !savedNames.has(p)));
}
setSectionStatusByKey((prev) => {
const updated = { ...prev };
for (const key of pendingKeys) {
@ -1015,7 +971,7 @@ export default function Settings() {
}
return updated;
});
}, [pendingDataBySection, pendingKeyToMenuKey, config]);
}, [pendingDataBySection, pendingKeyToMenuKey]);
const handleDialog = useCallback(
(save: boolean) => {
@ -1100,43 +1056,6 @@ export default function Settings() {
[],
);
const handleAddProfile = useCallback((id: string, friendlyName: string) => {
setNewProfiles((prev) => (prev.includes(id) ? prev : [...prev, id]));
// Stage the top-level profile definition for saving
setPendingDataBySection((prev) => ({
...prev,
[`__profile_def__.${id}`]: { friendly_name: friendlyName },
}));
}, []);
const handleRemoveNewProfile = useCallback((name: string) => {
setNewProfiles((prev) => prev.filter((p) => p !== name));
// Clear any editing state for this profile
setEditingProfile((prev) => {
const updated = { ...prev };
for (const key of Object.keys(updated)) {
if (updated[key] === name) {
delete updated[key];
}
}
return updated;
});
// Clear any pending data for this profile
setPendingDataBySection((prev) => {
const profileSegment = `profiles.${name}.`;
const updated = { ...prev };
let changed = false;
for (const key of Object.keys(updated)) {
if (key.includes(profileSegment)) {
delete updated[key];
changed = true;
}
}
return changed ? updated : prev;
});
}, []);
const handleDeleteProfileSection = useCallback(
async (camera: string, section: string, profile: string) => {
try {
@ -1177,22 +1096,16 @@ export default function Settings() {
const profileState: ProfileState = useMemo(
() => ({
editingProfile,
newProfiles,
allProfileNames,
profileFriendlyNames,
onSelectProfile: handleSelectProfile,
onAddProfile: handleAddProfile,
onRemoveNewProfile: handleRemoveNewProfile,
onDeleteProfileSection: handleDeleteProfileSection,
}),
[
editingProfile,
newProfiles,
allProfileNames,
profileFriendlyNames,
handleSelectProfile,
handleAddProfile,
handleRemoveNewProfile,
handleDeleteProfileSection,
],
);

View File

@ -18,7 +18,6 @@ export type ProfilesApiResponse = {
export type ProfileState = {
editingProfile: Record<string, string | null>;
newProfiles: string[];
allProfileNames: string[];
profileFriendlyNames: Map<string, string>;
onSelectProfile: (
@ -26,8 +25,6 @@ export type ProfileState = {
section: string,
profile: string | null,
) => void;
onAddProfile: (id: string, friendlyName: string) => void;
onRemoveNewProfile: (name: string) => void;
onDeleteProfileSection: (
camera: string,
section: string,

View File

@ -165,16 +165,42 @@ export default function ProfilesView({
return data;
}, [config, allProfileNames]);
const [addingProfile, setAddingProfile] = useState(false);
const handleAddSubmit = useCallback(
(data: AddProfileForm) => {
async (data: AddProfileForm) => {
const id = data.name.trim();
const friendlyName = data.friendly_name.trim();
if (!id || !friendlyName) return;
profileState?.onAddProfile(id, friendlyName);
setAddDialogOpen(false);
addForm.reset();
setAddingProfile(true);
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: {
profiles: { [id]: { friendly_name: friendlyName } },
},
});
await updateConfig();
await updateProfiles();
toast.success(
t("profiles.createSuccess", {
ns: "views/settings",
profile: friendlyName,
}),
{ position: "top-center" },
);
setAddDialogOpen(false);
addForm.reset();
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
} finally {
setAddingProfile(false);
}
},
[profileState, addForm],
[updateConfig, updateProfiles, addForm, t],
);
const handleActivateProfile = useCallback(
@ -213,14 +239,6 @@ export default function ProfilesView({
const handleDeleteProfile = useCallback(async () => {
if (!deleteProfile || !config) return;
// If this is an unsaved (new) profile, just remove it from local state
const isNewProfile = profileState?.newProfiles.includes(deleteProfile);
if (isNewProfile) {
profileState?.onRemoveNewProfile(deleteProfile);
setDeleteProfile(null);
return;
}
setDeleting(true);
try {
@ -254,9 +272,6 @@ export default function ProfilesView({
await updateConfig();
await updateProfiles();
// Also clean up local newProfiles state if this profile was in it
profileState?.onRemoveNewProfile(deleteProfile);
toast.success(
t("profiles.deleteSuccess", {
ns: "views/settings",
@ -281,7 +296,6 @@ export default function ProfilesView({
deleteProfile,
activeProfile,
config,
profileState,
profileFriendlyNames,
updateConfig,
updateProfiles,
@ -609,6 +623,7 @@ export default function ProfilesView({
type="button"
variant="outline"
onClick={() => setAddDialogOpen(false)}
disabled={addingProfile}
>
{t("button.cancel", { ns: "common" })}
</Button>
@ -616,10 +631,14 @@ export default function ProfilesView({
type="submit"
variant="select"
disabled={
addingProfile ||
!addForm.watch("friendly_name").trim() ||
!addForm.watch("name").trim()
}
>
{addingProfile && (
<ActivityIndicator className="mr-2 size-4" />
)}
{t("button.add", { ns: "common" })}
</Button>
</DialogFooter>