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 { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores";
|
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 { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
||||||
import { fromByteArray, toByteArray } from "base64-js";
|
import { fromByteArray, toByteArray } from "base64-js";
|
||||||
import { useEffect, useState } from "react";
|
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" });
|
removeChange({ type: "config", variant: "security" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import type { Types } from "@meshtastic/core";
|
import type { Types } from "@meshtastic/core";
|
||||||
|
|
||||||
// Config type discriminators
|
// Config type discriminators
|
||||||
export type ValidConfigType =
|
export type ValidRadioConfigType = "lora" | "security";
|
||||||
|
|
||||||
|
export type ValidDeviceConfigType =
|
||||||
| "device"
|
| "device"
|
||||||
| "position"
|
| "position"
|
||||||
| "power"
|
| "power"
|
||||||
| "network"
|
| "network"
|
||||||
| "display"
|
| "display"
|
||||||
| "lora"
|
| "bluetooth";
|
||||||
| "bluetooth"
|
|
||||||
| "security";
|
export type ValidConfigType = ValidRadioConfigType | ValidDeviceConfigType;
|
||||||
|
|
||||||
export type ValidModuleConfigType =
|
export type ValidModuleConfigType =
|
||||||
| "mqtt"
|
| "mqtt"
|
||||||
@@ -157,6 +159,47 @@ export function getConfigChangeCount(registry: ChangeRegistry): number {
|
|||||||
return count;
|
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
|
* Get count of module config changes
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export const mockDeviceStore: Device = {
|
|||||||
hasChannelChange: vi.fn().mockReturnValue(false),
|
hasChannelChange: vi.fn().mockReturnValue(false),
|
||||||
hasUserChange: vi.fn().mockReturnValue(false),
|
hasUserChange: vi.fn().mockReturnValue(false),
|
||||||
getConfigChangeCount: vi.fn().mockReturnValue(0),
|
getConfigChangeCount: vi.fn().mockReturnValue(0),
|
||||||
|
getRadioConfigChangeCount: vi.fn().mockReturnValue(0),
|
||||||
|
getDeviceConfigChangeCount: vi.fn().mockReturnValue(0),
|
||||||
getModuleConfigChangeCount: vi.fn().mockReturnValue(0),
|
getModuleConfigChangeCount: vi.fn().mockReturnValue(0),
|
||||||
getChannelChangeCount: vi.fn().mockReturnValue(0),
|
getChannelChangeCount: vi.fn().mockReturnValue(0),
|
||||||
getAllConfigChanges: vi.fn().mockReturnValue([]),
|
getAllConfigChanges: vi.fn().mockReturnValue([]),
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
getAllModuleConfigChanges,
|
getAllModuleConfigChanges,
|
||||||
getChannelChangeCount,
|
getChannelChangeCount,
|
||||||
getConfigChangeCount,
|
getConfigChangeCount,
|
||||||
|
getRadioConfigChangeCount,
|
||||||
|
getDeviceConfigChangeCount,
|
||||||
getModuleConfigChangeCount,
|
getModuleConfigChangeCount,
|
||||||
hasChannelChange,
|
hasChannelChange,
|
||||||
hasConfigChange,
|
hasConfigChange,
|
||||||
@@ -119,6 +121,8 @@ export interface Device extends DeviceData {
|
|||||||
hasChannelChange: (index: Types.ChannelNumber) => boolean;
|
hasChannelChange: (index: Types.ChannelNumber) => boolean;
|
||||||
hasUserChange: () => boolean;
|
hasUserChange: () => boolean;
|
||||||
getConfigChangeCount: () => number;
|
getConfigChangeCount: () => number;
|
||||||
|
getRadioConfigChangeCount: () => number;
|
||||||
|
getDeviceConfigChangeCount: () => number;
|
||||||
getModuleConfigChangeCount: () => number;
|
getModuleConfigChangeCount: () => number;
|
||||||
getChannelChangeCount: () => number;
|
getChannelChangeCount: () => number;
|
||||||
getAllConfigChanges: () => Protobuf.Config.Config[];
|
getAllConfigChanges: () => Protobuf.Config.Config[];
|
||||||
@@ -817,6 +821,24 @@ function deviceFactory(
|
|||||||
return getConfigChangeCount(device.changeRegistry);
|
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: () => {
|
getModuleConfigChangeCount: () => {
|
||||||
const device = get().devices.get(id);
|
const device = get().devices.get(id);
|
||||||
if (!device) {
|
if (!device) {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ function isUint8Array(v: unknown): v is Uint8Array {
|
|||||||
return v instanceof 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 {
|
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
if (a.byteLength !== b.byteLength) {
|
if (a.byteLength !== b.byteLength) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const ConfigPage = () => {
|
|||||||
setModuleConfig,
|
setModuleConfig,
|
||||||
addChannel,
|
addChannel,
|
||||||
getConfigChangeCount,
|
getConfigChangeCount,
|
||||||
|
getRadioConfigChangeCount,
|
||||||
|
getDeviceConfigChangeCount,
|
||||||
getModuleConfigChangeCount,
|
getModuleConfigChangeCount,
|
||||||
getChannelChangeCount,
|
getChannelChangeCount,
|
||||||
getAdminMessageChangeCount,
|
getAdminMessageChangeCount,
|
||||||
@@ -50,9 +52,9 @@ const ConfigPage = () => {
|
|||||||
const routerState = useRouterState();
|
const routerState = useRouterState();
|
||||||
const { t } = useTranslation("config");
|
const { t } = useTranslation("config");
|
||||||
|
|
||||||
const configChangeCount = getConfigChangeCount();
|
const radioConfigChangeCount = getRadioConfigChangeCount();
|
||||||
|
const deviceConfigChangeCount = getDeviceConfigChangeCount();
|
||||||
const moduleConfigChangeCount = getModuleConfigChangeCount();
|
const moduleConfigChangeCount = getModuleConfigChangeCount();
|
||||||
const channelChangeCount = getChannelChangeCount();
|
|
||||||
const adminMessageChangeCount = getAdminMessageChangeCount();
|
const adminMessageChangeCount = getAdminMessageChangeCount();
|
||||||
|
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
@@ -62,7 +64,7 @@ const ConfigPage = () => {
|
|||||||
route: radioRoute,
|
route: radioRoute,
|
||||||
label: t("navigation.radioConfig"),
|
label: t("navigation.radioConfig"),
|
||||||
icon: RadioTowerIcon,
|
icon: RadioTowerIcon,
|
||||||
changeCount: configChangeCount,
|
changeCount: radioConfigChangeCount,
|
||||||
component: RadioConfig,
|
component: RadioConfig,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,7 +72,7 @@ const ConfigPage = () => {
|
|||||||
route: deviceRoute,
|
route: deviceRoute,
|
||||||
label: t("navigation.deviceConfig"),
|
label: t("navigation.deviceConfig"),
|
||||||
icon: RouterIcon,
|
icon: RouterIcon,
|
||||||
changeCount: moduleConfigChangeCount,
|
changeCount: deviceConfigChangeCount,
|
||||||
component: DeviceConfig,
|
component: DeviceConfig,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -78,11 +80,11 @@ const ConfigPage = () => {
|
|||||||
route: moduleRoute,
|
route: moduleRoute,
|
||||||
label: t("navigation.moduleConfig"),
|
label: t("navigation.moduleConfig"),
|
||||||
icon: LayersIcon,
|
icon: LayersIcon,
|
||||||
changeCount: channelChangeCount,
|
changeCount: moduleConfigChangeCount,
|
||||||
component: ModuleConfig,
|
component: ModuleConfig,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, configChangeCount, moduleConfigChangeCount, channelChangeCount],
|
[t, radioConfigChangeCount, deviceConfigChangeCount, moduleConfigChangeCount],
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeSection =
|
const activeSection =
|
||||||
@@ -263,7 +265,7 @@ const ConfigPage = () => {
|
|||||||
getModuleConfigChangeCount() > 0 ||
|
getModuleConfigChangeCount() > 0 ||
|
||||||
getChannelChangeCount() > 0 ||
|
getChannelChangeCount() > 0 ||
|
||||||
adminMessageChangeCount > 0;
|
adminMessageChangeCount > 0;
|
||||||
const hasPending = hasDrafts || rhfState.isDirty;
|
const hasPending = hasDrafts;
|
||||||
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
|
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
|
||||||
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
|
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user