(
key={action.id}
type="button"
className={cn(
- "inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 hover:rounded-md dark:hover:rounded-md",
+ "inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 last:hover:rounded-r-md last:dark:hover:rounded-r-md",
+ disabled && "text-slate-300 dark:text-slate-600",
action.id === "copy-value" && isCopied &&
"text-green-600 dark:text-green-500",
)}
diff --git a/src/components/UI/Sidebar/SidebarButton.tsx b/src/components/UI/Sidebar/SidebarButton.tsx
index 920fb08c..8dc1aeca 100644
--- a/src/components/UI/Sidebar/SidebarButton.tsx
+++ b/src/components/UI/Sidebar/SidebarButton.tsx
@@ -13,6 +13,7 @@ export interface SidebarButtonProps {
onClick?: () => void;
disabled?: boolean;
preventCollapse?: boolean;
+ isDirty?: boolean;
}
export const SidebarButton = ({
@@ -24,6 +25,7 @@ export const SidebarButton = ({
onClick,
disabled = false,
preventCollapse = false,
+ isDirty,
}: SidebarButtonProps) => {
const { isCollapsed: isSidebarCollapsed } = useSidebar();
const isButtonCollapsed = isSidebarCollapsed && !preventCollapse;
@@ -64,13 +66,14 @@ export const SidebarButton = ({
{label}
- {!isButtonCollapsed && !active && count && count > 0 && (
+ {!isButtonCollapsed && ((!active && count && count > 0) || isDirty) && (
{count}
diff --git a/src/core/stores/deviceStore.mock.ts b/src/core/stores/deviceStore.mock.ts
index fb97c9d3..43af9a9e 100644
--- a/src/core/stores/deviceStore.mock.ts
+++ b/src/core/stores/deviceStore.mock.ts
@@ -44,12 +44,19 @@ export const mockDeviceStore: Device = {
unsafeRoles: false,
refreshKeys: false,
deleteMessages: false,
+ managedMode: false,
},
setStatus: vi.fn(),
setConfig: vi.fn(),
setModuleConfig: vi.fn(),
setWorkingConfig: vi.fn(),
setWorkingModuleConfig: vi.fn(),
+ getWorkingConfig: vi.fn(),
+ getWorkingModuleConfig: vi.fn(),
+ removeWorkingConfig: vi.fn(),
+ removeWorkingModuleConfig: vi.fn(),
+ getEffectiveConfig: vi.fn(),
+ getEffectiveModuleConfig: vi.fn(),
setHardware: vi.fn(),
setActiveNode: vi.fn(),
setPendingSettingsChanges: vi.fn(),
diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts
index 488a5d5d..5b8d20c0 100644
--- a/src/core/stores/deviceStore.ts
+++ b/src/core/stores/deviceStore.ts
@@ -18,6 +18,14 @@ type NodeError = {
node: number;
error: string;
};
+export type ValidConfigType = Exclude<
+ Protobuf.Config.Config["payloadVariant"]["case"],
+ "deviceUi" | "sessionkey" | undefined
+>;
+export type ValidModuleConfigType = Exclude<
+ Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"],
+ undefined
+>;
export interface Device {
id: number;
@@ -54,6 +62,7 @@ export interface Device {
unsafeRoles: boolean;
refreshKeys: boolean;
deleteMessages: boolean;
+ managedMode: boolean;
};
setStatus: (status: Types.DeviceStatusEnum) => void;
@@ -61,6 +70,26 @@ export interface Device {
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setWorkingConfig: (config: Protobuf.Config.Config) => void;
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
+ getWorkingConfig: (
+ payloadVariant: ValidConfigType,
+ ) =>
+ | Protobuf.LocalOnly.LocalConfig[Exclude
]
+ | undefined;
+ getWorkingModuleConfig: (
+ payloadVariant: ValidModuleConfigType,
+ ) =>
+ | Protobuf.LocalOnly.LocalModuleConfig[
+ Exclude
+ ]
+ | undefined;
+ removeWorkingConfig: (payloadVariant?: ValidConfigType) => void;
+ removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void;
+ getEffectiveConfig(
+ payloadVariant: K,
+ ): Protobuf.LocalOnly.LocalConfig[K] | undefined;
+ getEffectiveModuleConfig(
+ payloadVariant: K,
+ ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void;
@@ -142,6 +171,7 @@ export const useDeviceStore = createStore((set, get) => ({
refreshKeys: false,
rebootOTA: false,
deleteMessages: false,
+ managedMode: false,
},
pendingSettingsChanges: false,
messageDraft: "",
@@ -277,6 +307,7 @@ export const useDeviceStore = createStore((set, get) => ({
const index = device.workingConfig.findIndex(
(wc) => wc.payloadVariant.case === config.payloadVariant.case,
);
+
if (index !== -1) {
device.workingConfig[index] = config;
} else {
@@ -297,6 +328,7 @@ export const useDeviceStore = createStore((set, get) => ({
wmc.payloadVariant.case ===
moduleConfig.payloadVariant.case,
);
+
if (index !== -1) {
device.workingModuleConfig[index] = moduleConfig;
} else {
@@ -305,6 +337,106 @@ export const useDeviceStore = createStore((set, get) => ({
}),
);
},
+
+ getWorkingConfig: (payloadVariant: ValidConfigType) => {
+ const device = get().devices.get(id);
+ if (!device) return;
+
+ const workingConfig = device.workingConfig.find(
+ (c) => c.payloadVariant.case === payloadVariant,
+ );
+
+ if (
+ workingConfig?.payloadVariant.case === "deviceUi" ||
+ workingConfig?.payloadVariant.case === "sessionkey"
+ ) return;
+
+ return workingConfig?.payloadVariant.value;
+ },
+ getWorkingModuleConfig: (payloadVariant: ValidModuleConfigType) => {
+ const device = get().devices.get(id);
+ if (!device) return;
+
+ return device.workingModuleConfig.find(
+ (c) => c.payloadVariant.case === payloadVariant,
+ )?.payloadVariant.value;
+ },
+
+ removeWorkingConfig: (payloadVariant?: ValidConfigType) => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (!device) return;
+
+ if (!payloadVariant) {
+ device.workingConfig = [];
+ return;
+ }
+
+ const index = device.workingConfig.findIndex(
+ (wc: Protobuf.Config.Config) =>
+ wc.payloadVariant.case === payloadVariant,
+ );
+
+ if (index !== -1) {
+ device.workingConfig.splice(index, 1);
+ }
+ }),
+ );
+ },
+ removeWorkingModuleConfig: (
+ payloadVariant?: ValidModuleConfigType,
+ ) => {
+ set(
+ produce((draft) => {
+ const device = draft.devices.get(id);
+ if (!device) return;
+
+ if (!payloadVariant) {
+ device.workingModuleConfig = [];
+ return;
+ }
+
+ const index = device.workingModuleConfig.findIndex(
+ (wc: Protobuf.ModuleConfig.ModuleConfig) =>
+ wc.payloadVariant.case === payloadVariant,
+ );
+
+ if (index !== -1) {
+ device.workingModuleConfig.splice(index, 1);
+ }
+ }),
+ );
+ },
+
+ getEffectiveConfig(
+ payloadVariant: K,
+ ): Protobuf.LocalOnly.LocalConfig[K] | undefined {
+ if (!payloadVariant) return;
+ const device = get().devices.get(id);
+ if (!device) return;
+
+ return {
+ ...device.config[payloadVariant],
+ ...(device.workingConfig.find(
+ (c) => c.payloadVariant.case === payloadVariant,
+ )?.payloadVariant.value),
+ };
+ },
+ getEffectiveModuleConfig(
+ payloadVariant: K,
+ ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined {
+ const device = get().devices.get(id);
+ if (!device) return;
+
+ return {
+ ...device.moduleConfig[payloadVariant],
+ ...(device.workingModuleConfig.find(
+ (c) => c.payloadVariant.case === payloadVariant,
+ )?.payloadVariant.value),
+ };
+ },
+
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
set(
produce((draft) => {
diff --git a/src/core/utils/deepCompareConfig.test.ts b/src/core/utils/deepCompareConfig.test.ts
new file mode 100644
index 00000000..b504d2b7
--- /dev/null
+++ b/src/core/utils/deepCompareConfig.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { deepCompareConfig } from "./deepCompareConfig.ts";
+
+describe("deepCompareConfig", () => {
+ it("returns true for identical primitives", () => {
+ expect(deepCompareConfig(5, 5)).toBe(true);
+ expect(deepCompareConfig("foo", "foo")).toBe(true);
+ expect(deepCompareConfig(true, true)).toBe(true);
+ });
+
+ it("returns false for different primitives", () => {
+ expect(deepCompareConfig(5, 6)).toBe(false);
+ expect(deepCompareConfig("foo", "bar")).toBe(false);
+ expect(deepCompareConfig(true, false)).toBe(false);
+ });
+
+ it("handles nulls correctly", () => {
+ expect(deepCompareConfig(null, null)).toBe(true);
+ expect(deepCompareConfig(null, undefined)).toBe(false);
+ expect(deepCompareConfig(null, {})).toBe(false);
+ });
+
+ it("allows undefined in working when allowUndefined is true", () => {
+ expect(deepCompareConfig({ a: 1 }, { a: undefined }, true)).toBe(true);
+ expect(deepCompareConfig([1, 2, 3], [1, undefined, 3], true)).toBe(true);
+ });
+
+ it("rejects undefined in working when allowUndefined is false", () => {
+ expect(deepCompareConfig({ a: 1 }, { a: undefined }, false)).toBe(false);
+ });
+
+ it("compares arrays deeply", () => {
+ expect(deepCompareConfig([1, [2, 3]], [1, [2, 3]])).toBe(true);
+ expect(deepCompareConfig([1, [2, 3]], [1, [2, 4]])).toBe(false);
+ });
+
+ it("compares objects deeply", () => {
+ const existing = { x: 10, y: { z: 20 } };
+ const workingEqual = { x: 10, y: { z: 20 } };
+ const workingDiff = { x: 10, y: { z: 21 } };
+
+ expect(deepCompareConfig(existing, workingEqual)).toBe(true);
+ expect(deepCompareConfig(existing, workingDiff)).toBe(false);
+ });
+
+ it("ignores $typeName key in existing", () => {
+ const existing = { $typeName: "Test", a: 1 };
+ const working = { a: 1 };
+ expect(deepCompareConfig(existing, working)).toBe(true);
+ });
+
+ it("fails when working has extra keys", () => {
+ expect(deepCompareConfig({ a: 1 }, { a: 1, b: 2 })).toBe(false);
+ });
+
+ it("allows working arrays to be shorter if allowUndefined is true", () => {
+ expect(deepCompareConfig([1, 2, 3, 4], [1, 2], true)).toBe(true);
+ expect(deepCompareConfig([1, 2, 3, 4], [1, 2], false)).toBe(false);
+ });
+});
diff --git a/src/core/utils/deepCompareConfig.ts b/src/core/utils/deepCompareConfig.ts
new file mode 100644
index 00000000..aca12452
--- /dev/null
+++ b/src/core/utils/deepCompareConfig.ts
@@ -0,0 +1,58 @@
+function isObject(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export function deepCompareConfig(
+ a: unknown,
+ b: unknown,
+ allowUndefined = false,
+): boolean {
+ if (a === b) {
+ return true;
+ }
+
+ // If allowUndefined is true, and one is undefined, they are considered equal. // This check is placed early to simplify subsequent logic.
+ if (allowUndefined && (a === undefined || b === undefined)) {
+ return true;
+ }
+
+ if (typeof a !== typeof b || a === null || b === null) {
+ return false;
+ }
+
+ if (Array.isArray(a) && Array.isArray(b)) {
+ if (a.length !== b.length && !allowUndefined) {
+ return false;
+ }
+
+ const longestLength = Math.max(a.length, b.length);
+ for (let i = 0; i < longestLength; i++) {
+ if (!deepCompareConfig(a[i], b[i], allowUndefined)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ if (isObject(a) && isObject(b)) {
+ const aKeys = Object.keys(a);
+ const bKeys = Object.keys(b);
+ const allKeys = new Set([...aKeys, ...bKeys]);
+
+ for (const key of allKeys) {
+ if (key === "$typeName") {
+ continue;
+ }
+
+ const aValue = a[key];
+ const bValue = b[key];
+
+ if (!deepCompareConfig(aValue, bValue, allowUndefined)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ return false;
+}
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 5597f53a..0fb0756b 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -72,6 +72,7 @@
"unset": "UNSET",
"fallbackName": "Meshtastic {{last4}}",
"formValidation": {
+ "unsavedChanges": "Unsaved changes",
"tooBig": {
"string": "Too long, expected less than or equal to {{maximum}} characters.",
"number": "Too big, expected a number smaller than or equal to {{maximum}}.",
diff --git a/src/i18n/locales/en/dialog.json b/src/i18n/locales/en/dialog.json
index 28427867..ddf1f352 100644
--- a/src/i18n/locales/en/dialog.json
+++ b/src/i18n/locales/en/dialog.json
@@ -162,5 +162,10 @@
"choosingRightDeviceRole": "Choosing The Right Device Role",
"deviceRoleDocumentation": "Device Role Documentation",
"title": "Are you sure?"
+ },
+ "managedMode": {
+ "confirmUnderstanding": "Yes, I know what I'm doing",
+ "title": "Are you sure?",
+ "description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can only be changed through Remote Admin messages. This setting is not required for remote node administration."
}
}
diff --git a/src/pages/Config/DeviceConfig.tsx b/src/pages/Config/DeviceConfig.tsx
index 8a42d0c4..314d5f26 100644
--- a/src/pages/Config/DeviceConfig.tsx
+++ b/src/pages/Config/DeviceConfig.tsx
@@ -12,45 +12,77 @@ import {
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
+import { Spinner } from "@components/UI/Spinner.tsx";
import { useTranslation } from "react-i18next";
+import { useDevice, type ValidConfigType } from "@core/stores/deviceStore.ts";
+import { useMemo } from "react";
+import { type ComponentType, Suspense } from "react";
+import type { UseFormReturn } from "react-hook-form";
+import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
-export const DeviceConfig = () => {
+interface ConfigProps {
+ // We can get rid of this exception if we import every config schema and pass the union type
+ // deno-lint-ignore no-explicit-any
+ onFormInit: (methods: UseFormReturn) => void;
+}
+type TabItem = {
+ case: ValidConfigType;
+ label: string;
+ element: ComponentType;
+ count?: number;
+};
+
+export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
+ const { getWorkingConfig } = useDevice();
const { t } = useTranslation("deviceConfig");
- const tabs = [
+ const tabs: TabItem[] = [
{
+ case: "device",
label: t("page.tabDevice"),
element: Device,
count: 0,
},
{
+ case: "position",
label: t("page.tabPosition"),
element: Position,
},
{
+ case: "power",
label: t("page.tabPower"),
element: Power,
},
{
+ case: "network",
label: t("page.tabNetwork"),
element: Network,
},
{
+ case: "display",
label: t("page.tabDisplay"),
element: Display,
},
{
+ case: "lora",
label: t("page.tabLora"),
element: LoRa,
},
{
+ case: "bluetooth",
label: t("page.tabBluetooth"),
element: Bluetooth,
},
{
+ case: "security",
label: t("page.tabSecurity"),
element: Security,
},
- ];
+ ] as const;
+
+ const flags = useMemo(
+ () => new Map(tabs.map((tab) => [tab.case, getWorkingConfig(tab.case)])),
+ [tabs, getWorkingConfig],
+ );
return (
@@ -59,15 +91,27 @@ export const DeviceConfig = () => {
{tab.label}
+ {flags.get(tab.case) && (
+
+
+
+
+
+
+ )}
))}
{tabs.map((tab) => (
-
+ }>
+
+
+
+
))}
diff --git a/src/pages/Config/ModuleConfig.tsx b/src/pages/Config/ModuleConfig.tsx
index 4800cf2b..db0cb106 100644
--- a/src/pages/Config/ModuleConfig.tsx
+++ b/src/pages/Config/ModuleConfig.tsx
@@ -16,60 +16,96 @@ import {
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
+import { Spinner } from "@components/UI/Spinner.tsx";
import { useTranslation } from "react-i18next";
+import {
+ useDevice,
+ type ValidModuleConfigType,
+} from "@core/stores/deviceStore.ts";
+import { useMemo } from "react";
+import { type ComponentType, Suspense } from "react";
+import type { UseFormReturn } from "react-hook-form";
+import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
+
+interface ConfigProps {
+ // We can get rid of this exception if we import every config schema and pass the union type
+ // deno-lint-ignore no-explicit-any
+ onFormInit: (methods: UseFormReturn) => void;
+}
+type TabItem = {
+ case: ValidModuleConfigType;
+ label: string;
+ element: ComponentType;
+ count?: number;
+};
-export const ModuleConfig = () => {
+export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
+ const { getWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
- const tabs = [
+ const tabs: TabItem[] = [
{
+ case: "mqtt",
label: t("page.tabMqtt"),
element: MQTT,
},
{
+ case: "serial",
label: t("page.tabSerial"),
element: Serial,
},
{
+ case: "externalNotification",
label: t("page.tabExternalNotification"),
element: ExternalNotification,
},
{
+ case: "storeForward",
label: t("page.tabStoreAndForward"),
element: StoreForward,
},
{
+ case: "rangeTest",
label: t("page.tabRangeTest"),
element: RangeTest,
},
{
+ case: "telemetry",
label: t("page.tabTelemetry"),
element: Telemetry,
},
{
+ case: "cannedMessage",
label: t("page.tabCannedMessage"),
element: CannedMessage,
},
{
+ case: "audio",
label: t("page.tabAudio"),
element: Audio,
},
{
+ case: "neighborInfo",
label: t("page.tabNeighborInfo"),
element: NeighborInfo,
},
{
+ case: "ambientLighting",
label: t("page.tabAmbientLighting"),
element: AmbientLighting,
},
{
+ case: "detectionSensor",
label: t("page.tabDetectionSensor"),
element: DetectionSensor,
},
- {
- label: t("page.tabPaxcounter"),
- element: Paxcounter,
- },
- ];
+ { case: "paxcounter", label: t("page.tabPaxcounter"), element: Paxcounter },
+ ] as const;
+
+ const flags = useMemo(
+ () =>
+ new Map(tabs.map((tab) => [tab.case, getWorkingModuleConfig(tab.case)])),
+ [tabs, getWorkingModuleConfig],
+ );
return (
@@ -78,15 +114,27 @@ export const ModuleConfig = () => {
{tab.label}
+ {flags.get(tab.case) && (
+
+
+
+
+
+
+ )}
))}
{tabs.map((tab) => (
-
+ }>
+
+
+
+
))}
diff --git a/src/pages/Config/index.tsx b/src/pages/Config/index.tsx
index 337c2883..98725b0f 100644
--- a/src/pages/Config/index.tsx
+++ b/src/pages/Config/index.tsx
@@ -1,27 +1,49 @@
-import { useAppStore } from "../../core/stores/appStore.ts";
+import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
-import { SidebarButton } from "../../components/UI/Sidebar/SidebarButton.tsx";
+import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
+
import { useToast } from "@core/hooks/useToast.ts";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
-import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
+import {
+ BoxesIcon,
+ RefreshCwIcon,
+ SaveIcon,
+ SaveOff,
+ SettingsIcon,
+} from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
+import { cn } from "@core/utils/cn.ts";
+import type { UseFormReturn } from "react-hook-form";
const ConfigPage = () => {
- const { workingConfig, workingModuleConfig, connection } = useDevice();
+ const {
+ workingConfig,
+ workingModuleConfig,
+ connection,
+ removeWorkingConfig,
+ removeWorkingModuleConfig,
+ setConfig,
+ setModuleConfig,
+ } = useDevice();
const { hasErrors } = useAppStore();
+
const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module"
>("device");
const [isSaving, setIsSaving] = useState(false);
+ const [formMethods, setFormMethods] = useState(null);
const { toast } = useToast();
- const isError = hasErrors();
const { t } = useTranslation("deviceConfig");
+ const onFormInit = (methods: UseFormReturn) => {
+ setFormMethods(methods);
+ };
+
const handleSave = async () => {
if (hasErrors()) {
return toast({
@@ -31,36 +53,49 @@ const ConfigPage = () => {
}
setIsSaving(true);
+
try {
- if (activeConfigSection === "device") {
- await Promise.all(
- workingConfig.map((config) =>
- connection?.setConfig(config).then(() =>
- toast({
- title: t("toast.saveSuccess.title"),
- description: t("toast.saveSuccess.description", {
- case: config.payloadVariant.case,
- }),
- })
- )
- ),
- );
- } else {
- await Promise.all(
- workingModuleConfig.map((moduleConfig) =>
- connection?.setModuleConfig(moduleConfig).then(() =>
- toast({
- title: t("toast.saveSuccess.title"),
- description: t("toast.saveSuccess.description", {
- case: moduleConfig.payloadVariant.case,
- }),
- })
- )
- ),
+ await Promise.all(
+ workingConfig.map((newConfig) =>
+ connection?.setConfig(newConfig).then(() => {
+ toast({
+ title: t("toast.saveSuccess.title"),
+ description: t("toast.saveSuccess.description", {
+ case: newConfig.payloadVariant.case,
+ }),
+ });
+ })
+ ),
+ );
+
+ await Promise.all(
+ workingModuleConfig.map((newModuleConfig) =>
+ connection?.setModuleConfig(newModuleConfig).then(() =>
+ toast({
+ title: t("toast.saveSuccess.title"),
+ description: t("toast.saveSuccess.description", {
+ case: newModuleConfig.payloadVariant.case,
+ }),
+ })
+ )
+ ),
+ );
+
+ await connection?.commitEditSettings().then(() => {
+ if (formMethods) {
+ formMethods.reset({}, {
+ keepValues: true,
+ });
+ }
+
+ workingConfig.map((newConfig) => setConfig(newConfig));
+ workingModuleConfig.map((newModuleConfig) =>
+ setModuleConfig(newModuleConfig)
);
- setIsSaving(false);
- }
- await connection?.commitEditSettings();
+
+ removeWorkingConfig();
+ removeWorkingModuleConfig();
+ });
} catch (_error) {
toast({
title: t("toast.configSaveError.title"),
@@ -71,6 +106,15 @@ const ConfigPage = () => {
}
};
+ const handleReset = () => {
+ if (formMethods) {
+ formMethods.reset();
+ }
+
+ removeWorkingConfig();
+ removeWorkingModuleConfig();
+ };
+
const leftSidebar = useMemo(
() => (
@@ -83,19 +127,79 @@ const ConfigPage = () => {
active={activeConfigSection === "device"}
onClick={() => setActiveConfigSection("device")}
Icon={SettingsIcon}
+ isDirty={workingConfig.length > 0}
+ count={workingConfig.length}
/>
setActiveConfigSection("module")}
Icon={BoxesIcon}
+ isDirty={workingModuleConfig.length > 0}
+ count={workingModuleConfig.length}
/>
),
- [activeConfigSection],
+ [activeConfigSection, workingConfig, workingModuleConfig],
);
+ const buttonOpacity = useMemo(
+ () => (formMethods?.formState.isDirty &&
+ Object.keys(formMethods?.formState.dirtyFields ?? {}).length > 0 ||
+ workingConfig.length > 0 || workingModuleConfig.length > 0
+ ? "opacity-100"
+ : "opacity-0"),
+ [
+ formMethods?.formState.isDirty,
+ formMethods?.formState.dirtyFields,
+ workingConfig,
+ workingModuleConfig,
+ ],
+ );
+
+ const isValid = useMemo(() => {
+ return Object.keys(formMethods?.formState.errors ?? {}).length === 0;
+ }, [formMethods?.formState.errors]);
+
+ const actions = useMemo(() => [
+ {
+ key: "unsavedChanges",
+ label: t("common:formValidation.unsavedChanges"),
+ onClick: () => {},
+ className: cn([
+ "bg-blue-500 hover:bg-blue-500 text-white hover:text-white",
+ buttonOpacity,
+ "transition-opacity",
+ ]),
+ },
+ {
+ key: "reset",
+ icon: RefreshCwIcon,
+ label: t("common:button.reset"),
+ onClick: handleReset,
+ className: cn([buttonOpacity, "transition-opacity"]),
+ },
+ {
+ key: "save",
+ icon: !isValid ? SaveOff : SaveIcon,
+ isLoading: isSaving,
+ disabled: isSaving ||
+ !isValid ||
+ (workingConfig.length === 0 && workingModuleConfig.length === 0),
+ iconClasses: !isValid ? "text-red-400 cursor-not-allowed" : "",
+ onClick: handleSave,
+ label: t("common:button.save"),
+ },
+ ], [
+ activeConfigSection,
+ isSaving,
+ isValid,
+ buttonOpacity,
+ workingConfig,
+ workingModuleConfig,
+ ]);
+
return (
<>
{
label={activeConfigSection === "device"
? t("navigation.radioConfig")
: t("navigation.moduleConfig")}
- actions={[
- {
- key: "save",
- icon: isError ? SaveOff : SaveIcon,
- isLoading: isSaving,
- disabled: isSaving,
- iconClasses: isError ? "text-red-400 cursor-not-allowed" : "",
- onClick: handleSave,
- },
- ]}
+ actions={actions}
>
- {activeConfigSection === "device" ? : }
+ {activeConfigSection === "device"
+ ?
+ : }
>
);
diff --git a/src/validation/config/network.ts b/src/validation/config/network.ts
index 26ba9c36..cdb486b0 100644
--- a/src/validation/config/network.ts
+++ b/src/validation/config/network.ts
@@ -19,7 +19,7 @@ export const NetworkValidationSchema = z.object({
wifiEnabled: z.boolean(),
wifiSsid: z.string().max(33),
wifiPsk: z.string().max(64),
- ntpServer: z.string().min(2).max(33),
+ ntpServer: z.string().min(0).max(33),
ethEnabled: z.boolean(),
addressMode: AddressModeEnum,
ipv4Config: NetworkValidationIpV4ConfigSchema,
diff --git a/src/validation/config/security.test.ts b/src/validation/config/security.test.ts
index 08e74cce..428711c3 100644
--- a/src/validation/config/security.test.ts
+++ b/src/validation/config/security.test.ts
@@ -53,7 +53,7 @@ describe("RawSecuritySchema", () => {
if (!result.success) {
expect(
result.error.issues.some((i) =>
- i.message === "formValidation.adminKeyRequiredWhenManaged"
+ i.message === "formValidation.required.managed"
),
).toBe(true);
}
@@ -103,7 +103,7 @@ describe("ParsedSecuritySchema", () => {
if (!result.success) {
expect(
result.error.issues.some((i) =>
- i.message === "formValidation.adminKeyRequiredWhenManaged"
+ i.message === "formValidation.required.managed"
),
).toBe(true);
}
diff --git a/src/validation/config/security.ts b/src/validation/config/security.ts
index 2053b085..4d7bd83a 100644
--- a/src/validation/config/security.ts
+++ b/src/validation/config/security.ts
@@ -7,7 +7,7 @@ const {
isValidKey,
} = makePskHelpers([32]); // 256-bit
-const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged";
+const isManagedRequiredMsg = "formValidation.required.managed";
function makeSecuritySchema(
keyMaker: (optional: boolean) => ZodType,
diff --git a/src/validation/moduleConfig/mqtt.ts b/src/validation/moduleConfig/mqtt.ts
index 638f076f..fe47c93d 100644
--- a/src/validation/moduleConfig/mqtt.ts
+++ b/src/validation/moduleConfig/mqtt.ts
@@ -1,8 +1,8 @@
import { z } from "zod/v4";
export const MqttValidationMapReportSettingsSchema = z.object({
- publishIntervalSecs: z.number().optional(),
- positionPrecision: z.number().optional(),
+ publishIntervalSecs: z.coerce.number().int(),
+ positionPrecision: z.coerce.number().int(),
});
export const MqttValidationSchema = z.object({