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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user