fix(settings): fix unsaved change detection for non-admin-message config items (#1062)

Add getRadioConfigChangeCount() and getDeviceConfigChangeCount() for individual setting sections. This resolves displaying the count of unsaved changes on the wrong sidebar item.

Add normalizeBytes and call it from the Security page so that unset Admin Keys do not trigger unsaved changes when updating the page.

The Position page uses useMemo which does not clear rhfState.isDirty. Remove the rhfState.isDirty from hasPending to address it.
This commit is contained in:
fullburnen
2026-06-13 21:34:45 -04:00
committed by GitHub
parent 90f8219f8d
commit 87043f35cb
6 changed files with 102 additions and 13 deletions
@@ -9,7 +9,7 @@ import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { deepCompareConfig, normalizeBytes } from "@core/utils/deepCompareConfig.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import { fromByteArray, toByteArray } from "base64-js";
import { useEffect, useState } from "react";
@@ -87,7 +87,20 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
],
};
if (deepCompareConfig(config.security, payload, true)) {
// Normalize empty byte arrays -> undefined for comparison so
// empty base64 strings from the form match undefined/empty fields
// in the existing config and allow removeChange to work.
const normalizeSecurity = (s: ParsedSecurity | undefined) => {
if (!s) return s;
return {
...s,
privateKey: normalizeBytes(s.privateKey),
publicKey: normalizeBytes(s.publicKey),
adminKey: s.adminKey?.map((b) => normalizeBytes(b)) as unknown,
} as ParsedSecurity;
};
if (deepCompareConfig(normalizeSecurity(config.security), normalizeSecurity(payload), true)) {
removeChange({ type: "config", variant: "security" });
return;
}
@@ -1,15 +1,17 @@
import type { Types } from "@meshtastic/core";
// Config type discriminators
export type ValidConfigType =
export type ValidRadioConfigType = "lora" | "security";
export type ValidDeviceConfigType =
| "device"
| "position"
| "power"
| "network"
| "display"
| "lora"
| "bluetooth"
| "security";
| "bluetooth";
export type ValidConfigType = ValidRadioConfigType | ValidDeviceConfigType;
export type ValidModuleConfigType =
| "mqtt"
@@ -157,6 +159,47 @@ export function getConfigChangeCount(registry: ChangeRegistry): number {
return count;
}
/**
* Get count of radio config changes
*/
export function getRadioConfigChangeCount(registry: ChangeRegistry): number {
let count = 0;
for (const keyStr of registry.changes.keys()) {
const key = deserializeKey(keyStr);
if (key.type === "config" && (key.variant === "lora" || key.variant === "security")) {
count++;
}
}
// Channel is displayed under Radio section in UI, so include channel changes in the count
count += getChannelChangeCount(registry);
return count;
}
/**
* Get count of device config changes
*/
export function getDeviceConfigChangeCount(registry: ChangeRegistry): number {
let count = 0;
for (const keyStr of registry.changes.keys()) {
const key = deserializeKey(keyStr);
if (
key.type === "config" &&
(key.variant === "device" ||
key.variant === "position" ||
key.variant === "power" ||
key.variant === "network" ||
key.variant === "display" ||
key.variant === "bluetooth")
) {
count++;
}
}
if (hasUserChange(registry)) {
count++;
}
return count;
}
/**
* Get count of module config changes
*/
@@ -91,6 +91,8 @@ export const mockDeviceStore: Device = {
hasChannelChange: vi.fn().mockReturnValue(false),
hasUserChange: vi.fn().mockReturnValue(false),
getConfigChangeCount: vi.fn().mockReturnValue(0),
getRadioConfigChangeCount: vi.fn().mockReturnValue(0),
getDeviceConfigChangeCount: vi.fn().mockReturnValue(0),
getModuleConfigChangeCount: vi.fn().mockReturnValue(0),
getChannelChangeCount: vi.fn().mockReturnValue(0),
getAllConfigChanges: vi.fn().mockReturnValue([]),
@@ -15,6 +15,8 @@ import {
getAllModuleConfigChanges,
getChannelChangeCount,
getConfigChangeCount,
getRadioConfigChangeCount,
getDeviceConfigChangeCount,
getModuleConfigChangeCount,
hasChannelChange,
hasConfigChange,
@@ -119,6 +121,8 @@ export interface Device extends DeviceData {
hasChannelChange: (index: Types.ChannelNumber) => boolean;
hasUserChange: () => boolean;
getConfigChangeCount: () => number;
getRadioConfigChangeCount: () => number;
getDeviceConfigChangeCount: () => number;
getModuleConfigChangeCount: () => number;
getChannelChangeCount: () => number;
getAllConfigChanges: () => Protobuf.Config.Config[];
@@ -817,6 +821,24 @@ function deviceFactory(
return getConfigChangeCount(device.changeRegistry);
},
getRadioConfigChangeCount: () => {
const device = get().devices.get(id);
if (!device) {
return 0;
}
return getRadioConfigChangeCount(device.changeRegistry);
},
getDeviceConfigChangeCount: () => {
const device = get().devices.get(id);
if (!device) {
return 0;
}
return getDeviceConfigChangeCount(device.changeRegistry);
},
getModuleConfigChangeCount: () => {
const device = get().devices.get(id);
if (!device) {
@@ -6,6 +6,13 @@ function isUint8Array(v: unknown): v is Uint8Array {
return v instanceof Uint8Array;
}
export function normalizeBytes(value: unknown): unknown {
if (value instanceof Uint8Array && value.byteLength === 0) {
return undefined;
}
return value;
}
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) {
return false;
+9 -7
View File
@@ -36,6 +36,8 @@ const ConfigPage = () => {
setModuleConfig,
addChannel,
getConfigChangeCount,
getRadioConfigChangeCount,
getDeviceConfigChangeCount,
getModuleConfigChangeCount,
getChannelChangeCount,
getAdminMessageChangeCount,
@@ -50,9 +52,9 @@ const ConfigPage = () => {
const routerState = useRouterState();
const { t } = useTranslation("config");
const configChangeCount = getConfigChangeCount();
const radioConfigChangeCount = getRadioConfigChangeCount();
const deviceConfigChangeCount = getDeviceConfigChangeCount();
const moduleConfigChangeCount = getModuleConfigChangeCount();
const channelChangeCount = getChannelChangeCount();
const adminMessageChangeCount = getAdminMessageChangeCount();
const sections = useMemo(
@@ -62,7 +64,7 @@ const ConfigPage = () => {
route: radioRoute,
label: t("navigation.radioConfig"),
icon: RadioTowerIcon,
changeCount: configChangeCount,
changeCount: radioConfigChangeCount,
component: RadioConfig,
},
{
@@ -70,7 +72,7 @@ const ConfigPage = () => {
route: deviceRoute,
label: t("navigation.deviceConfig"),
icon: RouterIcon,
changeCount: moduleConfigChangeCount,
changeCount: deviceConfigChangeCount,
component: DeviceConfig,
},
{
@@ -78,11 +80,11 @@ const ConfigPage = () => {
route: moduleRoute,
label: t("navigation.moduleConfig"),
icon: LayersIcon,
changeCount: channelChangeCount,
changeCount: moduleConfigChangeCount,
component: ModuleConfig,
},
],
[t, configChangeCount, moduleConfigChangeCount, channelChangeCount],
[t, radioConfigChangeCount, deviceConfigChangeCount, moduleConfigChangeCount],
);
const activeSection =
@@ -263,7 +265,7 @@ const ConfigPage = () => {
getModuleConfigChangeCount() > 0 ||
getChannelChangeCount() > 0 ||
adminMessageChangeCount > 0;
const hasPending = hasDrafts || rhfState.isDirty;
const hasPending = hasDrafts;
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;