From 0cf677c8d5626551d30481dbaab5a0e13156b23a Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 24 Oct 2025 13:31:22 -0400 Subject: [PATCH] Feat(config): Align settings menu to match android/ios (#906) * feat: aligned settings menu to match android/ios * updated sidebar text size. * Update packages/web/public/i18n/locales/en/config.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/public/i18n/locales/en/config.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/src/components/PageComponents/Settings/User.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * linting/formatting fixes * fixed formatting issue --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../en/{deviceConfig.json => config.json} | 34 +- packages/web/public/i18n/locales/en/ui.json | 12 +- .../web/src/components/DeviceInfoPanel.tsx | 8 - .../src/components/Dialog/ImportDialog.tsx | 19 +- .../src/components/Form/FormMultiSelect.tsx | 2 +- .../web/src/components/LanguageSwitcher.tsx | 2 +- .../{ChannelConfig => Channels}/Channel.tsx | 21 +- .../PageComponents/Channels/Channels.tsx} | 10 +- .../ModuleConfig/AmbientLighting.tsx | 23 +- .../PageComponents/ModuleConfig/Audio.tsx | 22 +- .../ModuleConfig/CannedMessage.tsx | 22 +- .../ModuleConfig/DetectionSensor.tsx | 22 +- .../ModuleConfig/ExternalNotification.tsx | 23 +- .../PageComponents/ModuleConfig/MQTT.tsx | 17 +- .../ModuleConfig/NeighborInfo.tsx | 23 +- .../ModuleConfig/Paxcounter.tsx | 23 +- .../PageComponents/ModuleConfig/RangeTest.tsx | 23 +- .../PageComponents/ModuleConfig/Serial.tsx | 22 +- .../ModuleConfig/StoreForward.tsx | 23 +- .../PageComponents/ModuleConfig/Telemetry.tsx | 23 +- .../{Config => Settings}/Bluetooth.tsx | 17 +- .../{Config => Settings}/Device/index.tsx | 17 +- .../{Config => Settings}/Display.tsx | 17 +- .../{Config => Settings}/LoRa.tsx | 17 +- .../{Config => Settings}/Network/index.tsx | 16 +- .../{Config => Settings}/Position.tsx | 22 +- .../{Config => Settings}/Power.tsx | 18 +- .../Security/Security.tsx | 26 +- .../PageComponents/Settings/User.tsx | 111 +++++ packages/web/src/components/Sidebar.tsx | 4 +- .../components/UI/Sidebar/SidebarSection.tsx | 2 +- .../core/stores/deviceStore/changeRegistry.ts | 220 +++++++++ .../stores/deviceStore/deviceStore.mock.ts | 30 +- .../stores/deviceStore/deviceStore.test.ts | 161 ++++--- .../web/src/core/stores/deviceStore/index.ts | 443 ++++++++++-------- .../web/src/core/stores/deviceStore/types.ts | 15 +- packages/web/src/i18n-config.ts | 2 +- packages/web/src/pages/Messages.tsx | 2 +- .../{Config => Settings}/DeviceConfig.tsx | 51 +- .../{Config => Settings}/ModuleConfig.tsx | 6 +- .../web/src/pages/Settings/RadioConfig.tsx | 81 ++++ .../src/pages/{Config => Settings}/index.tsx | 184 ++++---- packages/web/src/routes.tsx | 32 +- packages/web/src/tests/setup.ts | 8 +- packages/web/src/validation/config/user.ts | 17 + 45 files changed, 1116 insertions(+), 777 deletions(-) rename packages/web/public/i18n/locales/en/{deviceConfig.json => config.json} (91%) rename packages/web/src/components/PageComponents/{ChannelConfig => Channels}/Channel.tsx (95%) rename packages/web/src/{pages/Config/ChannelConfig.tsx => components/PageComponents/Channels/Channels.tsx} (89%) rename packages/web/src/components/PageComponents/{Config => Settings}/Bluetooth.tsx (84%) rename packages/web/src/components/PageComponents/{Config => Settings}/Device/index.tsx (91%) rename packages/web/src/components/PageComponents/{Config => Settings}/Display.tsx (91%) rename packages/web/src/components/PageComponents/{Config => Settings}/LoRa.tsx (94%) rename packages/web/src/components/PageComponents/{Config => Settings}/Network/index.tsx (95%) rename packages/web/src/components/PageComponents/{Config => Settings}/Position.tsx (91%) rename packages/web/src/components/PageComponents/{Config => Settings}/Power.tsx (89%) rename packages/web/src/components/PageComponents/{Config => Settings}/Security/Security.tsx (95%) create mode 100644 packages/web/src/components/PageComponents/Settings/User.tsx create mode 100644 packages/web/src/core/stores/deviceStore/changeRegistry.ts rename packages/web/src/pages/{Config => Settings}/DeviceConfig.tsx (68%) rename packages/web/src/pages/{Config => Settings}/ModuleConfig.tsx (96%) create mode 100644 packages/web/src/pages/Settings/RadioConfig.tsx rename packages/web/src/pages/{Config => Settings}/index.tsx (64%) create mode 100644 packages/web/src/validation/config/user.ts diff --git a/packages/web/public/i18n/locales/en/deviceConfig.json b/packages/web/public/i18n/locales/en/config.json similarity index 91% rename from packages/web/public/i18n/locales/en/deviceConfig.json rename to packages/web/public/i18n/locales/en/config.json index 193c5494..ca59a850 100644 --- a/packages/web/public/i18n/locales/en/deviceConfig.json +++ b/packages/web/public/i18n/locales/en/config.json @@ -1,6 +1,8 @@ { "page": { - "title": "Configuration", + "title": "Settings", + "tabUser": "User", + "tabChannels": "Channels", "tabBluetooth": "Bluetooth", "tabDevice": "Device", "tabDisplay": "Display", @@ -11,7 +13,7 @@ "tabSecurity": "Security" }, "sidebar": { - "label": "Modules" + "label": "Configuration" }, "device": { "title": "Device Settings", @@ -424,5 +426,33 @@ "description": "Settings for Logging", "label": "Logging Settings" } + }, + "user": { + "title": "User Settings", + "description": "Configure your device name and identity settings", + "longName": { + "label": "Long Name", + "description": "Your full display name (1-40 characters)", + "validation": { + "min": "Long name must be at least 1 character", + "max": "Long name must be at most 40 characters" + } + }, + "shortName": { + "label": "Short Name", + "description": "Your abbreviated name (2-4 characters)", + "validation": { + "min": "Short name must be at least 2 characters", + "max": "Short name must be at most 4 characters" + } + }, + "isUnmessageable": { + "label": "Unmessageable", + "description": "Used to identify unmonitored or infrastructure nodes so that messaging is not available to nodes that will never respond." + }, + "isLicensed": { + "label": "Licensed amateur radio (HAM)", + "description": "Enable if you are a licensed amateur radio operator, enabling this option disables encryption and is not compatible with the default Meshtastic network." + } } } diff --git a/packages/web/public/i18n/locales/en/ui.json b/packages/web/public/i18n/locales/en/ui.json index 694e8982..18ea8092 100644 --- a/packages/web/public/i18n/locales/en/ui.json +++ b/packages/web/public/i18n/locales/en/ui.json @@ -3,11 +3,11 @@ "title": "Navigation", "messages": "Messages", "map": "Map", - "config": "Config", + "settings": "Settings", "channels": "Channels", "radioConfig": "Radio Config", + "deviceConfig": "Device Config", "moduleConfig": "Module Config", - "channelConfig": "Channel Config", "nodes": "Nodes" }, "app": { @@ -27,13 +27,7 @@ "title": "Firmware", "version": "v{{version}}", "buildDate": "Build date: {{date}}" - }, - "deviceName": { - "title": "Device Name", - "changeName": "Change Device Name", - "placeholder": "Enter device name" - }, - "editDeviceName": "Edit device name" + } } }, "batteryStatus": { diff --git a/packages/web/src/components/DeviceInfoPanel.tsx b/packages/web/src/components/DeviceInfoPanel.tsx index 6480cb39..7b781d4c 100644 --- a/packages/web/src/components/DeviceInfoPanel.tsx +++ b/packages/web/src/components/DeviceInfoPanel.tsx @@ -4,7 +4,6 @@ import { Languages, type LucideIcon, Palette, - PenLine, Search as SearchIcon, ZapIcon, } from "lucide-react"; @@ -53,7 +52,6 @@ export const DeviceInfoPanel = ({ firmwareVersion, user, isCollapsed, - setDialogOpen, setCommandPaletteOpen, disableHover = false, }: DeviceInfoPanelProps) => { @@ -91,12 +89,6 @@ export const DeviceInfoPanel = ({ icon: Palette, render: () => , }, - { - id: "changeName", - label: t("sidebar.deviceInfo.deviceName.changeName"), - icon: PenLine, - onClick: setDialogOpen, - }, { id: "commandMenu", label: t("page.title", { ns: "commandPalette" }), diff --git a/packages/web/src/components/Dialog/ImportDialog.tsx b/packages/web/src/components/Dialog/ImportDialog.tsx index 7b2fb8fd..71affa79 100644 --- a/packages/web/src/components/Dialog/ImportDialog.tsx +++ b/packages/web/src/components/Dialog/ImportDialog.tsx @@ -33,7 +33,7 @@ export interface ImportDialogProps { } export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { - const { config, channels } = useDevice(); + const { setChange, channels, config } = useDevice(); const { t } = useTranslation("dialog"); const [importDialogInput, setImportDialogInput] = useState(""); const [channelSet, setChannelSet] = useState(); @@ -41,8 +41,6 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { const [updateConfig, setUpdateConfig] = useState(true); const [importIndex, setImportIndex] = useState([]); - const { setWorkingChannelConfig, setWorkingConfig } = useDevice(); - useEffect(() => { // the channel information is contained in the URL's fragment, which will be present after a // non-URL encoded `#`. @@ -106,7 +104,11 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { true, ) ) { - setWorkingChannelConfig(payload); + setChange( + { type: "channel", index: importIndex[index] ?? 0 }, + payload, + channels.get(importIndex[index] ?? 0), + ); } }, ); @@ -118,14 +120,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { }; if (!deepCompareConfig(config.lora, payload, true)) { - setWorkingConfig( - create(Protobuf.Config.ConfigSchema, { - payloadVariant: { - case: "lora", - value: payload, - }, - }), - ); + setChange({ type: "config", variant: "lora" }, payload, config.lora); } } // Reset state after import diff --git a/packages/web/src/components/Form/FormMultiSelect.tsx b/packages/web/src/components/Form/FormMultiSelect.tsx index aa999439..6558a325 100644 --- a/packages/web/src/components/Form/FormMultiSelect.tsx +++ b/packages/web/src/components/Form/FormMultiSelect.tsx @@ -25,7 +25,7 @@ export function MultiSelectInput({ isDirty, invalid, }: GenericFormElementProps>) { - const { t } = useTranslation("deviceConfig"); + const { t } = useTranslation("config"); const { enumValue, className, ...remainingProperties } = field.properties; const isNewConfigStructure = diff --git a/packages/web/src/components/LanguageSwitcher.tsx b/packages/web/src/components/LanguageSwitcher.tsx index 7f4d2376..1ab94e0d 100644 --- a/packages/web/src/components/LanguageSwitcher.tsx +++ b/packages/web/src/components/LanguageSwitcher.tsx @@ -1,4 +1,4 @@ -import { type LangCode, supportedLanguages } from "@app/i18n-config.ts"; +import type { LangCode } from "@app/i18n-config.ts"; import useLang from "@core/hooks/useLang.ts"; import { cn } from "@core/utils/cn.ts"; import { Check, Languages } from "lucide-react"; diff --git a/packages/web/src/components/PageComponents/ChannelConfig/Channel.tsx b/packages/web/src/components/PageComponents/Channels/Channel.tsx similarity index 95% rename from packages/web/src/components/PageComponents/ChannelConfig/Channel.tsx rename to packages/web/src/components/PageComponents/Channels/Channel.tsx index 305dbacd..dedff891 100644 --- a/packages/web/src/components/PageComponents/ChannelConfig/Channel.tsx +++ b/packages/web/src/components/PageComponents/Channels/Channel.tsx @@ -24,12 +24,7 @@ export interface SettingsPanelProps { } export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { - const { - config, - setWorkingChannelConfig, - getWorkingChannelConfig, - removeWorkingChannelConfig, - } = useDevice(); + const { config, setChange, getChange, removeChange } = useDevice(); const { t } = useTranslation(["channels", "ui", "dialog"]); const defaultConfig = channel; @@ -51,7 +46,11 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { }, }; - const effectiveConfig = getWorkingChannelConfig(channel.index) ?? channel; + const workingChannel = getChange({ + type: "channel", + index: channel.index, + }) as Protobuf.Channel.Channel | undefined; + const effectiveConfig = workingChannel ?? channel; const formValues = { ...effectiveConfig, ...{ @@ -111,7 +110,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { return; } - const payload = { + const payload = create(Protobuf.Channel.ChannelSchema, { ...data, settings: { ...data.settings, @@ -121,14 +120,14 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { positionPrecision: data.settings.moduleSettings.positionPrecision, }), }, - }; + }); if (deepCompareConfig(channel, payload, true)) { - removeWorkingChannelConfig(channel.index); + removeChange({ type: "channel", index: channel.index }); return; } - setWorkingChannelConfig(create(Protobuf.Channel.ChannelSchema, payload)); + setChange({ type: "channel", index: channel.index }, payload, channel); }; const preSharedKeyRegenerate = async () => { diff --git a/packages/web/src/pages/Config/ChannelConfig.tsx b/packages/web/src/components/PageComponents/Channels/Channels.tsx similarity index 89% rename from packages/web/src/pages/Config/ChannelConfig.tsx rename to packages/web/src/components/PageComponents/Channels/Channels.tsx index 7ec004fd..ad8edfd1 100644 --- a/packages/web/src/pages/Config/ChannelConfig.tsx +++ b/packages/web/src/components/PageComponents/Channels/Channels.tsx @@ -1,4 +1,4 @@ -import { Channel } from "@app/components/PageComponents/ChannelConfig/Channel"; +import { Channel } from "@app/components/PageComponents/Channels/Channel"; import { Button } from "@components/UI/Button.tsx"; import { Spinner } from "@components/UI/Spinner.tsx"; import { @@ -30,8 +30,8 @@ export const getChannelName = (channel: Protobuf.Channel.Channel) => { }); }; -export const ChannelConfig = ({ onFormInit }: ConfigProps) => { - const { channels, getWorkingChannelConfig, setDialogOpen } = useDevice(); +export const Channels = ({ onFormInit }: ConfigProps) => { + const { channels, hasChannelChange, setDialogOpen } = useDevice(); const { t } = useTranslation("channels"); const allChannels = Array.from(channels.values()); @@ -40,10 +40,10 @@ export const ChannelConfig = ({ onFormInit }: ConfigProps) => { new Map( allChannels.map((channel) => [ channel.index, - getWorkingChannelConfig(channel.index), + hasChannelChange(channel.index), ]), ), - [allChannels, getWorkingChannelConfig], + [allChannels, hasChannelChange], ); return ( diff --git a/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx b/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx index 5ae258dd..108b3796 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx @@ -3,14 +3,12 @@ import { type AmbientLightingValidation, AmbientLightingValidationSchema, } from "@app/validation/moduleConfig/ambientLighting.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; -import { Protobuf } from "@meshtastic/core"; import { useTranslation } from "react-i18next"; interface AmbientLightingModuleConfigProps { @@ -21,27 +19,20 @@ export const AmbientLighting = ({ onFormInit, }: AmbientLightingModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "ambientLighting" }); - const { - moduleConfig, - setWorkingModuleConfig, - getEffectiveModuleConfig, - removeWorkingModuleConfig, - } = useDevice(); + const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = + useDevice(); const { t } = useTranslation("moduleConfig"); const onSubmit = (data: AmbientLightingValidation) => { if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) { - removeWorkingModuleConfig("ambientLighting"); + removeChange({ type: "moduleConfig", variant: "ambientLighting" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "ambientLighting", - value: data, - }, - }), + setChange( + { type: "moduleConfig", variant: "ambientLighting" }, + data, + moduleConfig.ambientLighting, ); }; diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx index eb7068f2..06738323 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx @@ -3,7 +3,6 @@ import { type AudioValidation, AudioValidationSchema, } from "@app/validation/moduleConfig/audio.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, @@ -19,27 +18,20 @@ interface AudioModuleConfigProps { export const Audio = ({ onFormInit }: AudioModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "audio" }); - const { - moduleConfig, - setWorkingModuleConfig, - getEffectiveModuleConfig, - removeWorkingModuleConfig, - } = useDevice(); + const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = + useDevice(); const { t } = useTranslation("moduleConfig"); const onSubmit = (data: AudioValidation) => { if (deepCompareConfig(moduleConfig.audio, data, true)) { - removeWorkingModuleConfig("audio"); + removeChange({ type: "moduleConfig", variant: "audio" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "audio", - value: data, - }, - }), + setChange( + { type: "moduleConfig", variant: "audio" }, + data, + moduleConfig.audio, ); }; diff --git a/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx b/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx index 711c1657..402fbdfe 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx @@ -3,7 +3,6 @@ import { type CannedMessageValidation, CannedMessageValidationSchema, } from "@app/validation/moduleConfig/cannedMessage.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, @@ -22,27 +21,20 @@ export const CannedMessage = ({ }: CannedMessageModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "cannedMessage" }); - const { - moduleConfig, - setWorkingModuleConfig, - getEffectiveModuleConfig, - removeWorkingModuleConfig, - } = useDevice(); + const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = + useDevice(); const { t } = useTranslation("moduleConfig"); const onSubmit = (data: CannedMessageValidation) => { if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) { - removeWorkingModuleConfig("cannedMessage"); + removeChange({ type: "moduleConfig", variant: "cannedMessage" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "cannedMessage", - value: data, - }, - }), + setChange( + { type: "moduleConfig", variant: "cannedMessage" }, + data, + moduleConfig.cannedMessage, ); }; diff --git a/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx b/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx index 5d041884..79d18847 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx @@ -3,7 +3,6 @@ import { type DetectionSensorValidation, DetectionSensorValidationSchema, } from "@app/validation/moduleConfig/detectionSensor.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, @@ -22,27 +21,20 @@ export const DetectionSensor = ({ }: DetectionSensorModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "detectionSensor" }); - const { - moduleConfig, - setWorkingModuleConfig, - getEffectiveModuleConfig, - removeWorkingModuleConfig, - } = useDevice(); + const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = + useDevice(); const { t } = useTranslation("moduleConfig"); const onSubmit = (data: DetectionSensorValidation) => { if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) { - removeWorkingModuleConfig("detectionSensor"); + removeChange({ type: "moduleConfig", variant: "detectionSensor" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "detectionSensor", - value: data, - }, - }), + setChange( + { type: "moduleConfig", variant: "detectionSensor" }, + data, + moduleConfig.detectionSensor, ); }; diff --git a/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx b/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx index 9bbb6178..e316ac78 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx @@ -3,14 +3,12 @@ import { type ExternalNotificationValidation, ExternalNotificationValidationSchema, } from "@app/validation/moduleConfig/externalNotification.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; -import { Protobuf } from "@meshtastic/core"; import { useTranslation } from "react-i18next"; interface ExternalNotificationModuleConfigProps { @@ -22,27 +20,20 @@ export const ExternalNotification = ({ }: ExternalNotificationModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "externalNotification" }); - const { - moduleConfig, - setWorkingModuleConfig, - getEffectiveModuleConfig, - removeWorkingModuleConfig, - } = useDevice(); + const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = + useDevice(); const { t } = useTranslation("moduleConfig"); const onSubmit = (data: ExternalNotificationValidation) => { if (deepCompareConfig(moduleConfig.externalNotification, data, true)) { - removeWorkingModuleConfig("externalNotification"); + removeChange({ type: "moduleConfig", variant: "externalNotification" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "externalNotification", - value: data, - }, - }), + setChange( + { type: "moduleConfig", variant: "externalNotification" }, + data, + moduleConfig.externalNotification, ); }; diff --git a/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx b/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx index c505a34a..c65c7aa8 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx @@ -23,9 +23,9 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => { const { config, moduleConfig, - setWorkingModuleConfig, + setChange, getEffectiveModuleConfig, - removeWorkingModuleConfig, + removeChange, } = useDevice(); const { t } = useTranslation("moduleConfig"); @@ -39,17 +39,14 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => { }; if (deepCompareConfig(moduleConfig.mqtt, payload, true)) { - removeWorkingModuleConfig("mqtt"); + removeChange({ type: "moduleConfig", variant: "mqtt" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "mqtt", - value: payload, - }, - }), + setChange( + { type: "moduleConfig", variant: "mqtt" }, + payload, + moduleConfig.mqtt, ); }; diff --git a/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx b/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx index 2e4f189f..1b134cbc 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx @@ -3,14 +3,12 @@ import { type NeighborInfoValidation, NeighborInfoValidationSchema, } from "@app/validation/moduleConfig/neighborInfo.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; -import { Protobuf } from "@meshtastic/core"; import { useTranslation } from "react-i18next"; interface NeighborInfoModuleConfigProps { @@ -20,27 +18,20 @@ interface NeighborInfoModuleConfigProps { export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "neighborInfo" }); - const { - moduleConfig, - setWorkingModuleConfig, - getEffectiveModuleConfig, - removeWorkingModuleConfig, - } = useDevice(); + const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = + useDevice(); const { t } = useTranslation("moduleConfig"); const onSubmit = (data: NeighborInfoValidation) => { if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) { - removeWorkingModuleConfig("neighborInfo"); + removeChange({ type: "moduleConfig", variant: "neighborInfo" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "neighborInfo", - value: data, - }, - }), + setChange( + { type: "moduleConfig", variant: "neighborInfo" }, + data, + moduleConfig.neighborInfo, ); }; diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx index d878c32a..13d636b0 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx @@ -3,14 +3,12 @@ import { type PaxcounterValidation, PaxcounterValidationSchema, } from "@app/validation/moduleConfig/paxcounter.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; -import { Protobuf } from "@meshtastic/core"; import { useTranslation } from "react-i18next"; interface PaxcounterModuleConfigProps { @@ -20,27 +18,20 @@ interface PaxcounterModuleConfigProps { export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "paxcounter" }); - const { - moduleConfig, - setWorkingModuleConfig, - getEffectiveModuleConfig, - removeWorkingModuleConfig, - } = useDevice(); + const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = + useDevice(); const { t } = useTranslation("moduleConfig"); const onSubmit = (data: PaxcounterValidation) => { if (deepCompareConfig(moduleConfig.paxcounter, data, true)) { - removeWorkingModuleConfig("paxcounter"); + removeChange({ type: "moduleConfig", variant: "paxcounter" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "paxcounter", - value: data, - }, - }), + setChange( + { type: "moduleConfig", variant: "paxcounter" }, + data, + moduleConfig.paxcounter, ); }; diff --git a/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx b/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx index 73c08fab..a360492e 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx @@ -3,14 +3,12 @@ import { type RangeTestValidation, RangeTestValidationSchema, } from "@app/validation/moduleConfig/rangeTest.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; -import { Protobuf } from "@meshtastic/core"; import { useTranslation } from "react-i18next"; interface RangeTestModuleConfigProps { @@ -20,28 +18,21 @@ interface RangeTestModuleConfigProps { export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "rangeTest" }); - const { - moduleConfig, - setWorkingModuleConfig, - getEffectiveModuleConfig, - removeWorkingModuleConfig, - } = useDevice(); + const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = + useDevice(); const { t } = useTranslation("moduleConfig"); const onSubmit = (data: RangeTestValidation) => { if (deepCompareConfig(moduleConfig.rangeTest, data, true)) { - removeWorkingModuleConfig("rangeTest"); + removeChange({ type: "moduleConfig", variant: "rangeTest" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "rangeTest", - value: data, - }, - }), + setChange( + { type: "moduleConfig", variant: "rangeTest" }, + data, + moduleConfig.rangeTest, ); }; diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx index 70843430..96de4bc6 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx @@ -3,7 +3,6 @@ import { type SerialValidation, SerialValidationSchema, } from "@app/validation/moduleConfig/serial.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, @@ -20,27 +19,20 @@ interface SerialModuleConfigProps { export const Serial = ({ onFormInit }: SerialModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "serial" }); - const { - moduleConfig, - setWorkingModuleConfig, - getEffectiveModuleConfig, - removeWorkingModuleConfig, - } = useDevice(); + const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = + useDevice(); const { t } = useTranslation("moduleConfig"); const onSubmit = (data: SerialValidation) => { if (deepCompareConfig(moduleConfig.serial, data, true)) { - removeWorkingModuleConfig("serial"); + removeChange({ type: "moduleConfig", variant: "serial" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "serial", - value: data, - }, - }), + setChange( + { type: "moduleConfig", variant: "serial" }, + data, + moduleConfig.serial, ); }; diff --git a/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx b/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx index eebb84e7..658362df 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx @@ -3,14 +3,12 @@ import { type StoreForwardValidation, StoreForwardValidationSchema, } from "@app/validation/moduleConfig/storeForward.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; -import { Protobuf } from "@meshtastic/core"; import { useTranslation } from "react-i18next"; interface StoreForwardModuleConfigProps { @@ -20,27 +18,20 @@ interface StoreForwardModuleConfigProps { export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "storeForward" }); - const { - moduleConfig, - setWorkingModuleConfig, - getEffectiveModuleConfig, - removeWorkingModuleConfig, - } = useDevice(); + const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = + useDevice(); const { t } = useTranslation("moduleConfig"); const onSubmit = (data: StoreForwardValidation) => { if (deepCompareConfig(moduleConfig.storeForward, data, true)) { - removeWorkingModuleConfig("storeForward"); + removeChange({ type: "moduleConfig", variant: "storeForward" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "storeForward", - value: data, - }, - }), + setChange( + { type: "moduleConfig", variant: "storeForward" }, + data, + moduleConfig.storeForward, ); }; diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx index 782c5022..c6d1384a 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx @@ -3,14 +3,12 @@ import { type TelemetryValidation, TelemetryValidationSchema, } from "@app/validation/moduleConfig/telemetry.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; -import { Protobuf } from "@meshtastic/core"; import { useTranslation } from "react-i18next"; interface TelemetryModuleConfigProps { @@ -20,27 +18,20 @@ interface TelemetryModuleConfigProps { export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => { useWaitForConfig({ moduleConfigCase: "telemetry" }); - const { - moduleConfig, - setWorkingModuleConfig, - getEffectiveModuleConfig, - removeWorkingModuleConfig, - } = useDevice(); + const { moduleConfig, setChange, getEffectiveModuleConfig, removeChange } = + useDevice(); const { t } = useTranslation("moduleConfig"); const onSubmit = (data: TelemetryValidation) => { if (deepCompareConfig(moduleConfig.telemetry, data, true)) { - removeWorkingModuleConfig("telemetry"); + removeChange({ type: "moduleConfig", variant: "telemetry" }); return; } - setWorkingModuleConfig( - create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "telemetry", - value: data, - }, - }), + setChange( + { type: "moduleConfig", variant: "telemetry" }, + data, + moduleConfig.telemetry, ); }; diff --git a/packages/web/src/components/PageComponents/Config/Bluetooth.tsx b/packages/web/src/components/PageComponents/Settings/Bluetooth.tsx similarity index 84% rename from packages/web/src/components/PageComponents/Config/Bluetooth.tsx rename to packages/web/src/components/PageComponents/Settings/Bluetooth.tsx index ee283cdc..423197c9 100644 --- a/packages/web/src/components/PageComponents/Config/Bluetooth.tsx +++ b/packages/web/src/components/PageComponents/Settings/Bluetooth.tsx @@ -3,7 +3,6 @@ import { type BluetoothValidation, BluetoothValidationSchema, } from "@app/validation/config/bluetooth.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, @@ -19,24 +18,16 @@ interface BluetoothConfigProps { export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => { useWaitForConfig({ configCase: "bluetooth" }); - const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } = - useDevice(); - const { t } = useTranslation("deviceConfig"); + const { config, setChange, getEffectiveConfig, removeChange } = useDevice(); + const { t } = useTranslation("config"); const onSubmit = (data: BluetoothValidation) => { if (deepCompareConfig(config.bluetooth, data, true)) { - removeWorkingConfig("bluetooth"); + removeChange({ type: "config", variant: "bluetooth" }); return; } - setWorkingConfig( - create(Protobuf.Config.ConfigSchema, { - payloadVariant: { - case: "bluetooth", - value: data, - }, - }), - ); + setChange({ type: "config", variant: "bluetooth" }, data, config.bluetooth); }; return ( diff --git a/packages/web/src/components/PageComponents/Config/Device/index.tsx b/packages/web/src/components/PageComponents/Settings/Device/index.tsx similarity index 91% rename from packages/web/src/components/PageComponents/Config/Device/index.tsx rename to packages/web/src/components/PageComponents/Settings/Device/index.tsx index 96afeeed..063eb682 100644 --- a/packages/web/src/components/PageComponents/Config/Device/index.tsx +++ b/packages/web/src/components/PageComponents/Settings/Device/index.tsx @@ -3,7 +3,6 @@ import { type DeviceValidation, DeviceValidationSchema, } from "@app/validation/config/device.ts"; -import { create } from "@bufbuild/protobuf"; import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; import { DynamicForm, @@ -20,25 +19,17 @@ interface DeviceConfigProps { export const Device = ({ onFormInit }: DeviceConfigProps) => { useWaitForConfig({ configCase: "device" }); - const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } = - useDevice(); - const { t } = useTranslation("deviceConfig"); + const { config, setChange, getEffectiveConfig, removeChange } = useDevice(); + const { t } = useTranslation("config"); const { validateRoleSelection } = useUnsafeRolesDialog(); const onSubmit = (data: DeviceValidation) => { if (deepCompareConfig(config.device, data, true)) { - removeWorkingConfig("device"); + removeChange({ type: "config", variant: "device" }); return; } - setWorkingConfig( - create(Protobuf.Config.ConfigSchema, { - payloadVariant: { - case: "device", - value: data, - }, - }), - ); + setChange({ type: "config", variant: "device" }, data, config.device); }; return ( diff --git a/packages/web/src/components/PageComponents/Config/Display.tsx b/packages/web/src/components/PageComponents/Settings/Display.tsx similarity index 91% rename from packages/web/src/components/PageComponents/Config/Display.tsx rename to packages/web/src/components/PageComponents/Settings/Display.tsx index f8e1ce36..bd8f6c81 100644 --- a/packages/web/src/components/PageComponents/Config/Display.tsx +++ b/packages/web/src/components/PageComponents/Settings/Display.tsx @@ -3,7 +3,6 @@ import { type DisplayValidation, DisplayValidationSchema, } from "@app/validation/config/display.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, @@ -18,24 +17,16 @@ interface DisplayConfigProps { } export const Display = ({ onFormInit }: DisplayConfigProps) => { useWaitForConfig({ configCase: "display" }); - const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } = - useDevice(); - const { t } = useTranslation("deviceConfig"); + const { config, setChange, getEffectiveConfig, removeChange } = useDevice(); + const { t } = useTranslation("config"); const onSubmit = (data: DisplayValidation) => { if (deepCompareConfig(config.display, data, true)) { - removeWorkingConfig("display"); + removeChange({ type: "config", variant: "display" }); return; } - setWorkingConfig( - create(Protobuf.Config.ConfigSchema, { - payloadVariant: { - case: "display", - value: data, - }, - }), - ); + setChange({ type: "config", variant: "display" }, data, config.display); }; return ( diff --git a/packages/web/src/components/PageComponents/Config/LoRa.tsx b/packages/web/src/components/PageComponents/Settings/LoRa.tsx similarity index 94% rename from packages/web/src/components/PageComponents/Config/LoRa.tsx rename to packages/web/src/components/PageComponents/Settings/LoRa.tsx index cfa7c612..844085df 100644 --- a/packages/web/src/components/PageComponents/Config/LoRa.tsx +++ b/packages/web/src/components/PageComponents/Settings/LoRa.tsx @@ -3,7 +3,6 @@ import { type LoRaValidation, LoRaValidationSchema, } from "@app/validation/config/lora.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, @@ -19,24 +18,16 @@ interface LoRaConfigProps { export const LoRa = ({ onFormInit }: LoRaConfigProps) => { useWaitForConfig({ configCase: "lora" }); - const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } = - useDevice(); - const { t } = useTranslation("deviceConfig"); + const { config, setChange, getEffectiveConfig, removeChange } = useDevice(); + const { t } = useTranslation("config"); const onSubmit = (data: LoRaValidation) => { if (deepCompareConfig(config.lora, data, true)) { - removeWorkingConfig("lora"); + removeChange({ type: "config", variant: "lora" }); return; } - setWorkingConfig( - create(Protobuf.Config.ConfigSchema, { - payloadVariant: { - case: "lora", - value: data, - }, - }), - ); + setChange({ type: "config", variant: "lora" }, data, config.lora); }; return ( diff --git a/packages/web/src/components/PageComponents/Config/Network/index.tsx b/packages/web/src/components/PageComponents/Settings/Network/index.tsx similarity index 95% rename from packages/web/src/components/PageComponents/Config/Network/index.tsx rename to packages/web/src/components/PageComponents/Settings/Network/index.tsx index b999d5dc..6b5bb63e 100644 --- a/packages/web/src/components/PageComponents/Config/Network/index.tsx +++ b/packages/web/src/components/PageComponents/Settings/Network/index.tsx @@ -23,9 +23,8 @@ interface NetworkConfigProps { export const Network = ({ onFormInit }: NetworkConfigProps) => { useWaitForConfig({ configCase: "network" }); - const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } = - useDevice(); - const { t } = useTranslation("deviceConfig"); + const { config, setChange, getEffectiveConfig, removeChange } = useDevice(); + const { t } = useTranslation("config"); const networkConfig = getEffectiveConfig("network"); @@ -44,18 +43,11 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => { }; if (deepCompareConfig(config.network, payload, true)) { - removeWorkingConfig("network"); + removeChange({ type: "config", variant: "network" }); return; } - setWorkingConfig( - create(Protobuf.Config.ConfigSchema, { - payloadVariant: { - case: "network", - value: payload, - }, - }), - ); + setChange({ type: "config", variant: "network" }, payload, config.network); }; return ( diff --git a/packages/web/src/components/PageComponents/Config/Position.tsx b/packages/web/src/components/PageComponents/Settings/Position.tsx similarity index 91% rename from packages/web/src/components/PageComponents/Config/Position.tsx rename to packages/web/src/components/PageComponents/Settings/Position.tsx index c52bc668..74e0a92e 100644 --- a/packages/web/src/components/PageComponents/Config/Position.tsx +++ b/packages/web/src/components/PageComponents/Settings/Position.tsx @@ -3,7 +3,6 @@ import { type PositionValidation, PositionValidationSchema, } from "@app/validation/config/position.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, @@ -24,26 +23,23 @@ interface PositionConfigProps { export const Position = ({ onFormInit }: PositionConfigProps) => { useWaitForConfig({ configCase: "position" }); - const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } = - useDevice(); + const { setChange, config, getEffectiveConfig, removeChange } = useDevice(); const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags( getEffectiveConfig("position")?.positionFlags ?? 0, ); - const { t } = useTranslation("deviceConfig"); + const { t } = useTranslation("config"); const onSubmit = (data: PositionValidation) => { - if (deepCompareConfig(config.position, data, true)) { - removeWorkingConfig("position"); + const payload = { ...data, positionFlags: flagsValue }; + if (deepCompareConfig(config.position, payload, true)) { + removeChange({ type: "config", variant: "position" }); return; } - return setWorkingConfig( - create(Protobuf.Config.ConfigSchema, { - payloadVariant: { - case: "position", - value: { ...data, positionFlags: flagsValue }, - }, - }), + return setChange( + { type: "config", variant: "position" }, + payload, + config.position, ); }; diff --git a/packages/web/src/components/PageComponents/Config/Power.tsx b/packages/web/src/components/PageComponents/Settings/Power.tsx similarity index 89% rename from packages/web/src/components/PageComponents/Config/Power.tsx rename to packages/web/src/components/PageComponents/Settings/Power.tsx index 323c1ec2..dcad23d9 100644 --- a/packages/web/src/components/PageComponents/Config/Power.tsx +++ b/packages/web/src/components/PageComponents/Settings/Power.tsx @@ -3,14 +3,12 @@ import { type PowerValidation, PowerValidationSchema, } from "@app/validation/config/power.ts"; -import { create } from "@bufbuild/protobuf"; import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; -import { Protobuf } from "@meshtastic/core"; import { useTranslation } from "react-i18next"; interface PowerConfigProps { @@ -19,24 +17,16 @@ interface PowerConfigProps { export const Power = ({ onFormInit }: PowerConfigProps) => { useWaitForConfig({ configCase: "power" }); - const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } = - useDevice(); - const { t } = useTranslation("deviceConfig"); + const { setChange, config, getEffectiveConfig, removeChange } = useDevice(); + const { t } = useTranslation("config"); const onSubmit = (data: PowerValidation) => { if (deepCompareConfig(config.power, data, true)) { - removeWorkingConfig("power"); + removeChange({ type: "config", variant: "power" }); return; } - setWorkingConfig( - create(Protobuf.Config.ConfigSchema, { - payloadVariant: { - case: "power", - value: data, - }, - }), - ); + setChange({ type: "config", variant: "power" }, data, config.power); }; return ( diff --git a/packages/web/src/components/PageComponents/Config/Security/Security.tsx b/packages/web/src/components/PageComponents/Settings/Security/Security.tsx similarity index 95% rename from packages/web/src/components/PageComponents/Config/Security/Security.tsx rename to packages/web/src/components/PageComponents/Settings/Security/Security.tsx index 21d620ea..a3e08e2c 100644 --- a/packages/web/src/components/PageComponents/Config/Security/Security.tsx +++ b/packages/web/src/components/PageComponents/Settings/Security/Security.tsx @@ -4,7 +4,6 @@ import { type RawSecurity, RawSecuritySchema, } from "@app/validation/config/security.ts"; -import { create } from "@bufbuild/protobuf"; import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx"; import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"; import { createZodResolver } from "@components/Form/createZodResolver.ts"; @@ -15,7 +14,6 @@ import { import { useDevice } from "@core/stores"; import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts"; -import { Protobuf } from "@meshtastic/core"; import { fromByteArray, toByteArray } from "base64-js"; import { useEffect, useState } from "react"; import { type DefaultValues, useForm } from "react-hook-form"; @@ -27,15 +25,10 @@ interface SecurityConfigProps { export const Security = ({ onFormInit }: SecurityConfigProps) => { useWaitForConfig({ configCase: "security" }); - const { - config, - setWorkingConfig, - setDialogOpen, - getEffectiveConfig, - removeWorkingConfig, - } = useDevice(); + const { config, setChange, setDialogOpen, getEffectiveConfig, removeChange } = + useDevice(); - const { t } = useTranslation("deviceConfig"); + const { t } = useTranslation("config"); const defaultConfig = config.security; const defaultValues = { @@ -103,17 +96,14 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { }; if (deepCompareConfig(config.security, payload, true)) { - removeWorkingConfig("security"); + removeChange({ type: "config", variant: "security" }); return; } - setWorkingConfig( - create(Protobuf.Config.ConfigSchema, { - payloadVariant: { - case: "security", - value: payload, - }, - }), + setChange( + { type: "config", variant: "security" }, + payload, + config.security, ); }; diff --git a/packages/web/src/components/PageComponents/Settings/User.tsx b/packages/web/src/components/PageComponents/Settings/User.tsx new file mode 100644 index 00000000..c4f094f1 --- /dev/null +++ b/packages/web/src/components/PageComponents/Settings/User.tsx @@ -0,0 +1,111 @@ +import { + type UserValidation, + UserValidationSchema, +} from "@app/validation/config/user.ts"; +import { create } from "@bufbuild/protobuf"; +import { + DynamicForm, + type DynamicFormFormInit, +} from "@components/Form/DynamicForm.tsx"; +import { useDevice, useNodeDB } from "@core/stores"; +import { Protobuf } from "@meshtastic/core"; +import { useTranslation } from "react-i18next"; + +interface UserConfigProps { + onFormInit: DynamicFormFormInit; +} + +export const User = ({ onFormInit }: UserConfigProps) => { + const { hardware, getChange, connection } = useDevice(); + const { getNode } = useNodeDB(); + const { t } = useTranslation("config"); + + const myNode = getNode(hardware.myNodeNum); + const defaultUser = myNode?.user ?? { + id: "", + longName: "", + shortName: "", + isLicensed: false, + }; + + // Get working copy from change registry + const workingUser = getChange({ type: "user" }) as + | Protobuf.Mesh.User + | undefined; + + const effectiveUser = workingUser ?? defaultUser; + + const onSubmit = (data: UserValidation) => { + connection?.setOwner( + create(Protobuf.Mesh.UserSchema, { + ...data, + }), + ); + }; + + return ( + + onSubmit={onSubmit} + onFormInit={onFormInit} + validationSchema={UserValidationSchema} + defaultValues={{ + longName: defaultUser.longName, + shortName: defaultUser.shortName, + isLicensed: defaultUser.isLicensed, + isUnmessageable: false, + }} + values={{ + longName: effectiveUser.longName, + shortName: effectiveUser.shortName, + isLicensed: effectiveUser.isLicensed, + isUnmessageable: false, + }} + fieldGroups={[ + { + label: t("user.title"), + description: t("user.description"), + fields: [ + { + type: "text", + name: "longName", + label: t("user.longName.label"), + description: t("user.longName.description"), + properties: { + fieldLength: { + min: 1, + max: 40, + showCharacterCount: true, + }, + }, + }, + { + type: "text", + name: "shortName", + label: t("user.shortName.label"), + description: t("user.shortName.description"), + properties: { + fieldLength: { + min: 2, + max: 4, + showCharacterCount: true, + }, + }, + }, + { + type: "toggle", + name: "isUnmessageable", + label: t("user.isUnmessageable.label"), + description: t("user.isUnmessageable.description"), + }, + { + type: "toggle", + name: "isLicensed", + label: t("user.isLicensed.label"), + description: t("user.isLicensed.description"), + }, + ], + }, + ]} + /> + ); +}; diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx index 8cc117d8..5f551d93 100644 --- a/packages/web/src/components/Sidebar.tsx +++ b/packages/web/src/components/Sidebar.tsx @@ -108,9 +108,9 @@ export const Sidebar = ({ children }: SidebarProps) => { }, { name: t("navigation.map"), icon: MapIcon, page: "map" }, { - name: t("navigation.config"), + name: t("navigation.settings"), icon: SettingsIcon, - page: "config", + page: "settings", }, { name: `${t("navigation.nodes")} (${displayedNodeCount})`, diff --git a/packages/web/src/components/UI/Sidebar/SidebarSection.tsx b/packages/web/src/components/UI/Sidebar/SidebarSection.tsx index f0b52d4b..e43c46bb 100644 --- a/packages/web/src/components/UI/Sidebar/SidebarSection.tsx +++ b/packages/web/src/components/UI/Sidebar/SidebarSection.tsx @@ -21,7 +21,7 @@ export const SidebarSection = ({ as="h3" className={cn( "mb-2", - "uppercase tracking-wider text-md", + "capitalize tracking-wider text-sm", "transition-all duration-300 ease-in-out", "whitespace-nowrap overflow-hidden", isCollapsed diff --git a/packages/web/src/core/stores/deviceStore/changeRegistry.ts b/packages/web/src/core/stores/deviceStore/changeRegistry.ts new file mode 100644 index 00000000..76de523e --- /dev/null +++ b/packages/web/src/core/stores/deviceStore/changeRegistry.ts @@ -0,0 +1,220 @@ +import type { Types } from "@meshtastic/core"; + +// Config type discriminators +export type ValidConfigType = + | "device" + | "position" + | "power" + | "network" + | "display" + | "lora" + | "bluetooth" + | "security"; + +export type ValidModuleConfigType = + | "mqtt" + | "serial" + | "externalNotification" + | "storeForward" + | "rangeTest" + | "telemetry" + | "cannedMessage" + | "audio" + | "neighborInfo" + | "ambientLighting" + | "detectionSensor" + | "paxcounter"; + +// Unified config change key type +export type ConfigChangeKey = + | { type: "config"; variant: ValidConfigType } + | { type: "moduleConfig"; variant: ValidModuleConfigType } + | { type: "channel"; index: Types.ChannelNumber } + | { type: "user" }; + +// Serialized key for Map storage +export type ConfigChangeKeyString = string; + +// Registry entry +export interface ChangeEntry { + key: ConfigChangeKey; + value: unknown; + timestamp: number; + originalValue?: unknown; +} + +// The unified registry +export interface ChangeRegistry { + changes: Map; +} + +/** + * Convert structured key to string for Map lookup + */ +export function serializeKey(key: ConfigChangeKey): ConfigChangeKeyString { + switch (key.type) { + case "config": + return `config:${key.variant}`; + case "moduleConfig": + return `moduleConfig:${key.variant}`; + case "channel": + return `channel:${key.index}`; + case "user": + return "user"; + } +} + +/** + * Reverse operation for type-safe retrieval + */ +export function deserializeKey(keyStr: ConfigChangeKeyString): ConfigChangeKey { + const [type, variant] = keyStr.split(":"); + + switch (type) { + case "config": + return { type: "config", variant: variant as ValidConfigType }; + case "moduleConfig": + return { + type: "moduleConfig", + variant: variant as ValidModuleConfigType, + }; + case "channel": + return { + type: "channel", + index: Number(variant) as Types.ChannelNumber, + }; + case "user": + return { type: "user" }; + default: + throw new Error(`Unknown key type: ${type}`); + } +} + +/** + * Create an empty change registry + */ +export function createChangeRegistry(): ChangeRegistry { + return { + changes: new Map(), + }; +} + +/** + * Check if a config variant has changes + */ +export function hasConfigChange( + registry: ChangeRegistry, + variant: ValidConfigType, +): boolean { + return registry.changes.has(serializeKey({ type: "config", variant })); +} + +/** + * Check if a module config variant has changes + */ +export function hasModuleConfigChange( + registry: ChangeRegistry, + variant: ValidModuleConfigType, +): boolean { + return registry.changes.has(serializeKey({ type: "moduleConfig", variant })); +} + +/** + * Check if a channel has changes + */ +export function hasChannelChange( + registry: ChangeRegistry, + index: Types.ChannelNumber, +): boolean { + return registry.changes.has(serializeKey({ type: "channel", index })); +} + +/** + * Check if user config has changes + */ +export function hasUserChange(registry: ChangeRegistry): boolean { + return registry.changes.has(serializeKey({ type: "user" })); +} + +/** + * Get count of config changes + */ +export function getConfigChangeCount(registry: ChangeRegistry): number { + let count = 0; + for (const keyStr of registry.changes.keys()) { + const key = deserializeKey(keyStr); + if (key.type === "config") { + count++; + } + } + return count; +} + +/** + * Get count of module config changes + */ +export function getModuleConfigChangeCount(registry: ChangeRegistry): number { + let count = 0; + for (const keyStr of registry.changes.keys()) { + const key = deserializeKey(keyStr); + if (key.type === "moduleConfig") { + count++; + } + } + return count; +} + +/** + * Get count of channel changes + */ +export function getChannelChangeCount(registry: ChangeRegistry): number { + let count = 0; + for (const keyStr of registry.changes.keys()) { + const key = deserializeKey(keyStr); + if (key.type === "channel") { + count++; + } + } + return count; +} + +/** + * Get all config changes as an array + */ +export function getAllConfigChanges(registry: ChangeRegistry): ChangeEntry[] { + const changes: ChangeEntry[] = []; + for (const entry of registry.changes.values()) { + if (entry.key.type === "config") { + changes.push(entry); + } + } + return changes; +} + +/** + * Get all module config changes as an array + */ +export function getAllModuleConfigChanges( + registry: ChangeRegistry, +): ChangeEntry[] { + const changes: ChangeEntry[] = []; + for (const entry of registry.changes.values()) { + if (entry.key.type === "moduleConfig") { + changes.push(entry); + } + } + return changes; +} + +/** + * Get all channel changes as an array + */ +export function getAllChannelChanges(registry: ChangeRegistry): ChangeEntry[] { + const changes: ChangeEntry[] = []; + for (const entry of registry.changes.values()) { + if (entry.key.type === "channel") { + changes.push(entry); + } + } + return changes; +} diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts index d3fd2255..eb67f7bf 100644 --- a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts +++ b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts @@ -19,9 +19,7 @@ export const mockDeviceStore: Device = { channels: new Map(), config: {} as Protobuf.LocalOnly.LocalConfig, moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig, - workingConfig: [], - workingModuleConfig: [], - workingChannelConfig: [], + changeRegistry: { changes: new Map() }, hardware: {} as Protobuf.Mesh.MyNodeInfo, metadata: new Map(), traceroutes: new Map(), @@ -56,17 +54,8 @@ export const mockDeviceStore: Device = { 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(), - setWorkingChannelConfig: vi.fn(), - getWorkingChannelConfig: vi.fn(), - removeWorkingChannelConfig: vi.fn(), setHardware: vi.fn(), setActiveNode: vi.fn(), setPendingSettingsChanges: vi.fn(), @@ -90,4 +79,21 @@ export const mockDeviceStore: Device = { getUnreadCount: vi.fn().mockReturnValue(0), getNeighborInfo: vi.fn(), addNeighborInfo: vi.fn(), + + // New unified change tracking methods + setChange: vi.fn(), + removeChange: vi.fn(), + hasChange: vi.fn().mockReturnValue(false), + getChange: vi.fn(), + clearAllChanges: vi.fn(), + hasConfigChange: vi.fn().mockReturnValue(false), + hasModuleConfigChange: vi.fn().mockReturnValue(false), + hasChannelChange: vi.fn().mockReturnValue(false), + hasUserChange: vi.fn().mockReturnValue(false), + getConfigChangeCount: vi.fn().mockReturnValue(0), + getModuleConfigChangeCount: vi.fn().mockReturnValue(0), + getChannelChangeCount: vi.fn().mockReturnValue(0), + getAllConfigChanges: vi.fn().mockReturnValue([]), + getAllModuleConfigChanges: vi.fn().mockReturnValue([]), + getAllChannelChanges: vi.fn().mockReturnValue([]), }; diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.test.ts b/packages/web/src/core/stores/deviceStore/deviceStore.test.ts index 8348b2b0..f7644bdd 100644 --- a/packages/web/src/core/stores/deviceStore/deviceStore.test.ts +++ b/packages/web/src/core/stores/deviceStore/deviceStore.test.ts @@ -52,25 +52,7 @@ function makeChannel(index: number) { function makeWaypoint(id: number, expire?: number) { return create(Protobuf.Mesh.WaypointSchema, { id, expire }); } -function makeConfig(fields: Record) { - return create(Protobuf.Config.ConfigSchema, { - payloadVariant: { - case: "device", - value: create(Protobuf.Config.Config_DeviceConfigSchema, fields), - }, - }); -} -function makeModuleConfig(fields: Record) { - return create(Protobuf.ModuleConfig.ModuleConfigSchema, { - payloadVariant: { - case: "mqtt", - value: create( - Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, - fields, - ), - }, - }); -} + function makeAdminMessage(fields: Record) { return create(Protobuf.Admin.AdminMessageSchema, fields); } @@ -114,13 +96,13 @@ describe("DeviceStore – basic map ops & retention", () => { }); }); -describe("DeviceStore – working/effective config API", () => { +describe("DeviceStore – change registry API", () => { beforeEach(() => { idbMem.clear(); vi.clearAllMocks(); }); - it("setWorkingConfig/getWorkingConfig replaces by variant and getEffectiveConfig merges base + working", async () => { + it("setChange/hasChange/getChange for config and getEffectiveConfig merges base + working", async () => { const { useDeviceStore } = await freshStore(false); const state = useDeviceStore.getState(); const device = state.addDevice(42); @@ -138,14 +120,17 @@ describe("DeviceStore – working/effective config API", () => { ); // working deviceConfig.role = ROUTER - device.setWorkingConfig( - makeConfig({ - role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER, - }), - ); + const routerConfig = create(Protobuf.Config.Config_DeviceConfigSchema, { + role: Protobuf.Config.Config_DeviceConfig_Role.ROUTER, + }); + device.setChange({ type: "config", variant: "device" }, routerConfig); - // expect working deviceConfig.role = ROUTER - const working = device.getWorkingConfig("device"); + // expect change is tracked + expect(device.hasConfigChange("device")).toBe(true); + const working = device.getChange({ + type: "config", + variant: "device", + }) as Protobuf.Config.Config_DeviceConfig; expect(working?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.ROUTER); // expect effective deviceConfig.role = ROUTER @@ -154,30 +139,27 @@ describe("DeviceStore – working/effective config API", () => { Protobuf.Config.Config_DeviceConfig_Role.ROUTER, ); - // remove working, effective should equal base - device.removeWorkingConfig("device"); - expect(device.getWorkingConfig("device")).toBeUndefined(); + // remove change, effective should equal base + device.removeChange({ type: "config", variant: "device" }); + expect(device.hasConfigChange("device")).toBe(false); expect(device.getEffectiveConfig("device")?.role).toBe( Protobuf.Config.Config_DeviceConfig_Role.CLIENT, ); // add multiple, then clear all - device.setWorkingConfig(makeConfig({})); - device.setWorkingConfig( - makeConfig({ - deviceRole: Protobuf.Config.Config_DeviceConfig_Role.ROUTER, - }), - ); - device.removeWorkingConfig(); // clears all - expect(device.getWorkingConfig("device")).toBeUndefined(); + device.setChange({ type: "config", variant: "device" }, routerConfig); + device.setChange({ type: "config", variant: "position" }, {}); + device.clearAllChanges(); + expect(device.hasConfigChange("device")).toBe(false); + expect(device.hasConfigChange("position")).toBe(false); }); - it("setWorkingModuleConfig/getWorkingModuleConfig and getEffectiveModuleConfig", async () => { + it("setChange/hasChange for moduleConfig and getEffectiveModuleConfig", async () => { const { useDeviceStore } = await freshStore(false); const state = useDeviceStore.getState(); const device = state.addDevice(7); - // base moduleConfig.mqtt empty; add working mqtt host + // base moduleConfig.mqtt with base address device.setModuleConfig( create(Protobuf.ModuleConfig.ModuleConfigSchema, { payloadVariant: { @@ -188,58 +170,82 @@ describe("DeviceStore – working/effective config API", () => { }, }), ); - device.setWorkingModuleConfig( - makeModuleConfig({ address: "mqtt://working" }), + + // working mqtt config + const workingMqtt = create( + Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, + { address: "mqtt://working" }, + ); + device.setChange({ type: "moduleConfig", variant: "mqtt" }, workingMqtt); + + expect(device.hasModuleConfigChange("mqtt")).toBe(true); + const mqtt = device.getChange({ + type: "moduleConfig", + variant: "mqtt", + }) as Protobuf.ModuleConfig.ModuleConfig_MQTTConfig; + expect(mqtt?.address).toBe("mqtt://working"); + + // effective should return working value + expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe( + "mqtt://working", ); - const mqtt = device.getWorkingModuleConfig("mqtt"); - expect(mqtt?.address).toBe("mqtt://working"); - expect(mqtt?.address).toBe("mqtt://working"); - - device.removeWorkingModuleConfig("mqtt"); - expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined(); + // remove change + device.removeChange({ type: "moduleConfig", variant: "mqtt" }); + expect(device.hasModuleConfigChange("mqtt")).toBe(false); expect(device.getEffectiveModuleConfig("mqtt")?.address).toBe( "mqtt://base", ); // Clear all - device.setWorkingModuleConfig(makeModuleConfig({ address: "x" })); - device.setWorkingModuleConfig(makeModuleConfig({ address: "y" })); - device.removeWorkingModuleConfig(); - expect(device.getWorkingModuleConfig("mqtt")).toBeUndefined(); + device.setChange({ type: "moduleConfig", variant: "mqtt" }, workingMqtt); + device.clearAllChanges(); + expect(device.hasModuleConfigChange("mqtt")).toBe(false); }); - it("channel working config add/update/remove/get", async () => { + it("channel change tracking add/update/remove/get", async () => { const { useDeviceStore } = await freshStore(false); const state = useDeviceStore.getState(); const device = state.addDevice(9); - device.setWorkingChannelConfig(makeChannel(0)); - device.setWorkingChannelConfig( - create(Protobuf.Channel.ChannelSchema, { - index: 1, - settings: { name: "one" }, - }), - ); - expect(device.getWorkingChannelConfig(0)?.index).toBe(0); - expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("one"); + const channel0 = makeChannel(0); + const channel1 = create(Protobuf.Channel.ChannelSchema, { + index: 1, + settings: { name: "one" }, + }); + + device.setChange({ type: "channel", index: 0 }, channel0); + device.setChange({ type: "channel", index: 1 }, channel1); + + expect(device.hasChannelChange(0)).toBe(true); + expect(device.hasChannelChange(1)).toBe(true); + const ch0 = device.getChange({ type: "channel", index: 0 }) as + | Protobuf.Channel.Channel + | undefined; + expect(ch0?.index).toBe(0); + const ch1 = device.getChange({ type: "channel", index: 1 }) as + | Protobuf.Channel.Channel + | undefined; + expect(ch1?.settings?.name).toBe("one"); // update channel 1 - device.setWorkingChannelConfig( - create(Protobuf.Channel.ChannelSchema, { - index: 1, - settings: { name: "uno" }, - }), - ); - expect(device.getWorkingChannelConfig(1)?.settings?.name).toBe("uno"); + const channel1Updated = create(Protobuf.Channel.ChannelSchema, { + index: 1, + settings: { name: "uno" }, + }); + device.setChange({ type: "channel", index: 1 }, channel1Updated); + const ch1Updated = device.getChange({ type: "channel", index: 1 }) as + | Protobuf.Channel.Channel + | undefined; + expect(ch1Updated?.settings?.name).toBe("uno"); // remove specific - device.removeWorkingChannelConfig(1); - expect(device.getWorkingChannelConfig(1)).toBeUndefined(); + device.removeChange({ type: "channel", index: 1 }); + expect(device.hasChannelChange(1)).toBe(false); // remove all - device.removeWorkingChannelConfig(); - expect(device.getWorkingChannelConfig(0)).toBeUndefined(); + device.clearAllChanges(); + expect(device.hasChannelChange(0)).toBe(false); }); }); @@ -349,7 +355,8 @@ describe("DeviceStore – traceroutes & waypoints retention + merge on setHardwa // Old device with myNodeNum=777 and some waypoints (one expired) const oldDevice = state.addDevice(1); - oldDevice.connection = { sendWaypoint: vi.fn() } as any; + const mockSendWaypoint = vi.fn(); + oldDevice.addConnection({ sendWaypoint: mockSendWaypoint } as any); oldDevice.setHardware(makeHardware(777)); oldDevice.addWaypoint( @@ -393,10 +400,10 @@ describe("DeviceStore – traceroutes & waypoints retention + merge on setHardwa // Remove waypoint oldDevice.removeWaypoint(102, false); - expect(oldDevice.connection?.sendWaypoint).not.toHaveBeenCalled(); + expect(mockSendWaypoint).not.toHaveBeenCalled(); await oldDevice.removeWaypoint(101, true); // toMesh=true - expect(oldDevice.connection?.sendWaypoint).toHaveBeenCalled(); + expect(mockSendWaypoint).toHaveBeenCalled(); expect(useDeviceStore.getState().devices.get(1)!.waypoints.length).toBe(98); diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts index f57f7f92..55b10935 100644 --- a/packages/web/src/core/stores/deviceStore/index.ts +++ b/packages/web/src/core/stores/deviceStore/index.ts @@ -10,6 +10,21 @@ import { persist, subscribeWithSelector, } from "zustand/middleware"; +import type { ChangeRegistry, ConfigChangeKey } from "./changeRegistry.ts"; +import { + createChangeRegistry, + getAllChannelChanges, + getAllConfigChanges, + getAllModuleConfigChanges, + getChannelChangeCount, + getConfigChangeCount, + getModuleConfigChangeCount, + hasChannelChange, + hasConfigChange, + hasModuleConfigChange, + hasUserChange, + serializeKey, +} from "./changeRegistry.ts"; import type { Dialogs, DialogVariant, @@ -42,9 +57,7 @@ export interface Device extends DeviceData { channels: Map; config: Protobuf.LocalOnly.LocalConfig; moduleConfig: Protobuf.LocalOnly.LocalModuleConfig; - workingConfig: Protobuf.Config.Config[]; - workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[]; - workingChannelConfig: Protobuf.Channel.Channel[]; + changeRegistry: ChangeRegistry; // Unified change tracking hardware: Protobuf.Mesh.MyNodeInfo; metadata: Map; connection?: MeshDevice; @@ -58,27 +71,12 @@ export interface Device extends DeviceData { setStatus: (status: Types.DeviceStatusEnum) => void; setConfig: (config: Protobuf.Config.Config) => void; setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; - setWorkingConfig: (config: Protobuf.Config.Config) => void; - setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; - getWorkingConfig( - payloadVariant: K, - ): Protobuf.LocalOnly.LocalConfig[K] | undefined; - getWorkingModuleConfig( - payloadVariant: K, - ): Protobuf.LocalOnly.LocalModuleConfig[K] | 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; - setWorkingChannelConfig: (channelNum: Protobuf.Channel.Channel) => void; - getWorkingChannelConfig: ( - index: Types.ChannelNumber, - ) => Protobuf.Channel.Channel | undefined; - removeWorkingChannelConfig: (channelNum?: Types.ChannelNumber) => void; setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void; setActiveNode: (node: number) => void; setPendingSettingsChanges: (state: boolean) => void; @@ -116,6 +114,27 @@ export interface Device extends DeviceData { neighborInfo: Protobuf.Mesh.NeighborInfo, ) => void; getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined; + + // New unified change tracking methods + setChange: ( + key: ConfigChangeKey, + value: unknown, + originalValue?: unknown, + ) => void; + removeChange: (key: ConfigChangeKey) => void; + hasChange: (key: ConfigChangeKey) => boolean; + getChange: (key: ConfigChangeKey) => unknown | undefined; + clearAllChanges: () => void; + hasConfigChange: (variant: ValidConfigType) => boolean; + hasModuleConfigChange: (variant: ValidModuleConfigType) => boolean; + hasChannelChange: (index: Types.ChannelNumber) => boolean; + hasUserChange: () => boolean; + getConfigChangeCount: () => number; + getModuleConfigChangeCount: () => number; + getChannelChangeCount: () => number; + getAllConfigChanges: () => Protobuf.Config.Config[]; + getAllModuleConfigChanges: () => Protobuf.ModuleConfig.ModuleConfig[]; + getAllChannelChanges: () => Protobuf.Channel.Channel[]; } export interface deviceState { @@ -157,9 +176,7 @@ function deviceFactory( channels: new Map(), config: create(Protobuf.LocalOnly.LocalConfigSchema), moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema), - workingConfig: [], - workingModuleConfig: [], - workingChannelConfig: [], + changeRegistry: createChangeRegistry(), hardware: create(Protobuf.Mesh.MyNodeInfoSchema), metadata: new Map(), connection: undefined, @@ -302,130 +319,6 @@ function deviceFactory( }), ); }, - setWorkingConfig: (config: Protobuf.Config.Config) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const index = device.workingConfig.findIndex( - (wc) => wc.payloadVariant.case === config.payloadVariant.case, - ); - - if (index !== -1) { - device.workingConfig[index] = config; - } else { - device.workingConfig.push(config); - } - }), - ); - }, - setWorkingModuleConfig: ( - moduleConfig: Protobuf.ModuleConfig.ModuleConfig, - ) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const index = device.workingModuleConfig.findIndex( - (wmc) => - wmc.payloadVariant.case === moduleConfig.payloadVariant.case, - ); - - if (index !== -1) { - device.workingModuleConfig[index] = moduleConfig; - } else { - device.workingModuleConfig.push(moduleConfig); - } - }), - ); - }, - - getWorkingConfig(payloadVariant: K) { - 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 as Protobuf.LocalOnly.LocalConfig[K]; - }, - getWorkingModuleConfig( - payloadVariant: K, - ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined { - const device = get().devices.get(id); - if (!device) { - return; - } - - return device.workingModuleConfig.find( - (c) => c.payloadVariant.case === payloadVariant, - )?.payloadVariant.value as Protobuf.LocalOnly.LocalModuleConfig[K]; - }, - - 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 { @@ -437,11 +330,13 @@ function deviceFactory( return; } + const workingValue = device.changeRegistry.changes.get( + serializeKey({ type: "config", variant: payloadVariant }), + )?.value as Protobuf.LocalOnly.LocalConfig[K] | undefined; + return { ...device.config[payloadVariant], - ...device.workingConfig.find( - (c) => c.payloadVariant.case === payloadVariant, - )?.payloadVariant.value, + ...workingValue, }; }, getEffectiveModuleConfig( @@ -452,69 +347,16 @@ function deviceFactory( return; } + const workingValue = device.changeRegistry.changes.get( + serializeKey({ type: "moduleConfig", variant: payloadVariant }), + )?.value as Protobuf.LocalOnly.LocalModuleConfig[K] | undefined; + return { ...device.moduleConfig[payloadVariant], - ...device.workingModuleConfig.find( - (c) => c.payloadVariant.case === payloadVariant, - )?.payloadVariant.value, + ...workingValue, }; }, - setWorkingChannelConfig: (config: Protobuf.Channel.Channel) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - const index = device.workingChannelConfig.findIndex( - (wcc) => wcc.index === config.index, - ); - - if (index !== -1) { - device.workingChannelConfig[index] = config; - } else { - device.workingChannelConfig.push(config); - } - }), - ); - }, - getWorkingChannelConfig: (channelNum: Types.ChannelNumber) => { - const device = get().devices.get(id); - if (!device) { - return; - } - - const workingChannelConfig = device.workingChannelConfig.find( - (c) => c.index === channelNum, - ); - - return workingChannelConfig; - }, - removeWorkingChannelConfig: (channelNum?: Types.ChannelNumber) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (!device) { - return; - } - - if (channelNum === undefined) { - device.workingChannelConfig = []; - return; - } - - const index = device.workingChannelConfig.findIndex( - (wcc: Protobuf.Channel.Channel) => wcc.index === channelNum, - ); - - if (index !== -1) { - device.workingChannelConfig.splice(index, 1); - } - }), - ); - }, - setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => { set( produce((draft) => { @@ -855,6 +697,195 @@ function deviceFactory( } return device.neighborInfo.get(nodeNum); }, + + // New unified change tracking methods + setChange: ( + key: ConfigChangeKey, + value: unknown, + originalValue?: unknown, + ) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + + const keyStr = serializeKey(key); + device.changeRegistry.changes.set(keyStr, { + key, + value, + originalValue, + timestamp: Date.now(), + }); + }), + ); + }, + + removeChange: (key: ConfigChangeKey) => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + + device.changeRegistry.changes.delete(serializeKey(key)); + }), + ); + }, + + hasChange: (key: ConfigChangeKey) => { + const device = get().devices.get(id); + return device?.changeRegistry.changes.has(serializeKey(key)) ?? false; + }, + + getChange: (key: ConfigChangeKey) => { + const device = get().devices.get(id); + if (!device) { + return; + } + + return device.changeRegistry.changes.get(serializeKey(key))?.value; + }, + + clearAllChanges: () => { + set( + produce((draft) => { + const device = draft.devices.get(id); + if (!device) { + return; + } + + device.changeRegistry.changes.clear(); + }), + ); + }, + + hasConfigChange: (variant: ValidConfigType) => { + const device = get().devices.get(id); + if (!device) { + return false; + } + + return hasConfigChange(device.changeRegistry, variant); + }, + + hasModuleConfigChange: (variant: ValidModuleConfigType) => { + const device = get().devices.get(id); + if (!device) { + return false; + } + + return hasModuleConfigChange(device.changeRegistry, variant); + }, + + hasChannelChange: (index: Types.ChannelNumber) => { + const device = get().devices.get(id); + if (!device) { + return false; + } + + return hasChannelChange(device.changeRegistry, index); + }, + + hasUserChange: () => { + const device = get().devices.get(id); + if (!device) { + return false; + } + + return hasUserChange(device.changeRegistry); + }, + + getConfigChangeCount: () => { + const device = get().devices.get(id); + if (!device) { + return 0; + } + + return getConfigChangeCount(device.changeRegistry); + }, + + getModuleConfigChangeCount: () => { + const device = get().devices.get(id); + if (!device) { + return 0; + } + + return getModuleConfigChangeCount(device.changeRegistry); + }, + + getChannelChangeCount: () => { + const device = get().devices.get(id); + if (!device) { + return 0; + } + + return getChannelChangeCount(device.changeRegistry); + }, + + getAllConfigChanges: () => { + const device = get().devices.get(id); + if (!device) { + return []; + } + + const changes = getAllConfigChanges(device.changeRegistry); + return changes + .map((entry) => { + if (entry.key.type !== "config") { + return null; + } + if (!entry.value) { + return null; + } + return create(Protobuf.Config.ConfigSchema, { + payloadVariant: { + case: entry.key.variant, + value: entry.value, + }, + }); + }) + .filter((c): c is Protobuf.Config.Config => c !== null); + }, + + getAllModuleConfigChanges: () => { + const device = get().devices.get(id); + if (!device) { + return []; + } + + const changes = getAllModuleConfigChanges(device.changeRegistry); + return changes + .map((entry) => { + if (entry.key.type !== "moduleConfig") { + return null; + } + if (!entry.value) { + return null; + } + return create(Protobuf.ModuleConfig.ModuleConfigSchema, { + payloadVariant: { + case: entry.key.variant, + value: entry.value, + }, + }); + }) + .filter((c): c is Protobuf.ModuleConfig.ModuleConfig => c !== null); + }, + + getAllChannelChanges: () => { + const device = get().devices.get(id); + if (!device) { + return []; + } + + const changes = getAllChannelChanges(device.changeRegistry); + return changes + .map((entry) => entry.value as Protobuf.Channel.Channel) + .filter((c): c is Protobuf.Channel.Channel => c !== undefined); + }, }; } diff --git a/packages/web/src/core/stores/deviceStore/types.ts b/packages/web/src/core/stores/deviceStore/types.ts index 08726372..c6df2a32 100644 --- a/packages/web/src/core/stores/deviceStore/types.ts +++ b/packages/web/src/core/stores/deviceStore/types.ts @@ -1,4 +1,8 @@ import type { Protobuf } from "@meshtastic/core"; +import type { + ValidConfigType, + ValidModuleConfigType, +} from "./changeRegistry.ts"; interface Dialogs { import: boolean; @@ -22,16 +26,7 @@ interface Dialogs { type DialogVariant = keyof Dialogs; -type ValidConfigType = Exclude< - Protobuf.Config.Config["payloadVariant"]["case"], - "deviceUi" | "sessionkey" | undefined ->; -type ValidModuleConfigType = Exclude< - Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"], - undefined ->; - -type Page = "messages" | "map" | "config" | "channels" | "nodes"; +type Page = "messages" | "map" | "settings" | "channels" | "nodes"; type WaypointWithMetadata = Protobuf.Mesh.Waypoint & { metadata: { diff --git a/packages/web/src/i18n-config.ts b/packages/web/src/i18n-config.ts index 8b8de390..dc8cde12 100644 --- a/packages/web/src/i18n-config.ts +++ b/packages/web/src/i18n-config.ts @@ -52,7 +52,7 @@ i18next "channels", "commandPalette", "common", - "deviceConfig", + "config", "moduleConfig", "dashboard", "dialog", diff --git a/packages/web/src/pages/Messages.tsx b/packages/web/src/pages/Messages.tsx index 83186a24..e2ed979c 100644 --- a/packages/web/src/pages/Messages.tsx +++ b/packages/web/src/pages/Messages.tsx @@ -19,7 +19,6 @@ import { import { cn } from "@core/utils/cn.ts"; import { randId } from "@core/utils/randId.ts"; import { Protobuf, Types } from "@meshtastic/core"; -import { getChannelName } from "@pages/Config/ChannelConfig.tsx"; import { useNavigate, useParams } from "@tanstack/react-router"; import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; import { @@ -30,6 +29,7 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; +import { getChannelName } from "../components/PageComponents/Channels/Channels.tsx"; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; diff --git a/packages/web/src/pages/Config/DeviceConfig.tsx b/packages/web/src/pages/Settings/DeviceConfig.tsx similarity index 68% rename from packages/web/src/pages/Config/DeviceConfig.tsx rename to packages/web/src/pages/Settings/DeviceConfig.tsx index ead1d4f5..034864e7 100644 --- a/packages/web/src/pages/Config/DeviceConfig.tsx +++ b/packages/web/src/pages/Settings/DeviceConfig.tsx @@ -1,11 +1,10 @@ -import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx"; -import { Device } from "@components/PageComponents/Config/Device/index.tsx"; -import { Display } from "@components/PageComponents/Config/Display.tsx"; -import { LoRa } from "@components/PageComponents/Config/LoRa.tsx"; -import { Network } from "@components/PageComponents/Config/Network/index.tsx"; -import { Position } from "@components/PageComponents/Config/Position.tsx"; -import { Power } from "@components/PageComponents/Config/Power.tsx"; -import { Security } from "@components/PageComponents/Config/Security/Security.tsx"; +import { Bluetooth } from "@components/PageComponents/Settings/Bluetooth.tsx"; +import { Device } from "@components/PageComponents/Settings/Device/index.tsx"; +import { Display } from "@components/PageComponents/Settings/Display.tsx"; +import { Network } from "@components/PageComponents/Settings/Network/index.tsx"; +import { Position } from "@components/PageComponents/Settings/Position.tsx"; +import { Power } from "@components/PageComponents/Settings/Power.tsx"; +import { User } from "@components/PageComponents/Settings/User.tsx"; import { Spinner } from "@components/UI/Spinner.tsx"; import { Tabs, @@ -23,21 +22,25 @@ interface ConfigProps { } type TabItem = { - case: ValidConfigType; + case: ValidConfigType | "user"; label: string; element: ComponentType; count?: number; }; export const DeviceConfig = ({ onFormInit }: ConfigProps) => { - const { getWorkingConfig } = useDevice(); - const { t } = useTranslation("deviceConfig"); + const { hasConfigChange, hasUserChange } = useDevice(); + const { t } = useTranslation("config"); const tabs: TabItem[] = [ + { + case: "user", + label: t("page.tabUser"), + element: User, + }, { case: "device", label: t("page.tabDevice"), element: Device, - count: 0, }, { case: "position", @@ -59,30 +62,28 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => { 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], + () => + new Map( + tabs.map((tab) => [ + tab.case, + tab.case === "user" + ? hasUserChange() + : hasConfigChange(tab.case as ValidConfigType), + ]), + ), + [tabs, hasConfigChange, hasUserChange], ); return ( - + {tabs.map((tab) => ( { - const { getWorkingModuleConfig } = useDevice(); + const { hasModuleConfigChange } = useDevice(); const { t } = useTranslation("moduleConfig"); const tabs: TabItem[] = [ { @@ -97,8 +97,8 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => { const flags = useMemo( () => - new Map(tabs.map((tab) => [tab.case, getWorkingModuleConfig(tab.case)])), - [tabs, getWorkingModuleConfig], + new Map(tabs.map((tab) => [tab.case, hasModuleConfigChange(tab.case)])), + [tabs, hasModuleConfigChange], ); return ( diff --git a/packages/web/src/pages/Settings/RadioConfig.tsx b/packages/web/src/pages/Settings/RadioConfig.tsx new file mode 100644 index 00000000..ddcc52c0 --- /dev/null +++ b/packages/web/src/pages/Settings/RadioConfig.tsx @@ -0,0 +1,81 @@ +import { Channels } from "@app/components/PageComponents/Channels/Channels"; +import { LoRa } from "@components/PageComponents/Settings/LoRa.tsx"; +import { Security } from "@components/PageComponents/Settings/Security/Security.tsx"; +import { Spinner } from "@components/UI/Spinner.tsx"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@components/UI/Tabs.tsx"; +import { useDevice, type ValidConfigType } from "@core/stores"; +import { type ComponentType, Suspense, useMemo } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +interface ConfigProps { + onFormInit: (methods: UseFormReturn) => void; +} + +type TabItem = { + case: ValidConfigType; + label: string; + element: ComponentType; + count?: number; +}; + +export const RadioConfig = ({ onFormInit }: ConfigProps) => { + const { hasConfigChange } = useDevice(); + const { t } = useTranslation("config"); + const tabs: TabItem[] = [ + { + case: "lora", + label: t("page.tabLora"), + element: LoRa, + }, + { + case: "channels", + label: t("page.tabChannels"), + element: Channels, + }, + { + case: "security", + label: t("page.tabSecurity"), + element: Security, + }, + ] as const; + + const flags = useMemo( + () => new Map(tabs.map((tab) => [tab.case, hasConfigChange(tab.case)])), + [tabs, hasConfigChange], + ); + + return ( + + + {tabs.map((tab) => ( + + {tab.label} + {flags.get(tab.case) && ( + + + + + )} + + ))} + + {tabs.map((tab) => ( + + }> + + + + ))} + + ); +}; diff --git a/packages/web/src/pages/Config/index.tsx b/packages/web/src/pages/Settings/index.tsx similarity index 64% rename from packages/web/src/pages/Config/index.tsx rename to packages/web/src/pages/Settings/index.tsx index 7c0531de..ffd17614 100644 --- a/packages/web/src/pages/Config/index.tsx +++ b/packages/web/src/pages/Settings/index.tsx @@ -1,3 +1,4 @@ +import { deviceRoute, moduleRoute, radioRoute } from "@app/routes"; import { PageLayout } from "@components/PageLayout.tsx"; import { Sidebar } from "@components/Sidebar.tsx"; import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; @@ -5,44 +6,84 @@ import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { useToast } from "@core/hooks/useToast.ts"; import { useDevice } from "@core/stores"; import { cn } from "@core/utils/cn.ts"; -import { ChannelConfig } from "@pages/Config/ChannelConfig.tsx"; -import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx"; -import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx"; +import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx"; +import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; import { - BoxesIcon, LayersIcon, + RadioTowerIcon, RefreshCwIcon, + RouterIcon, SaveIcon, SaveOff, - SettingsIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { FieldValues, UseFormReturn } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { RadioConfig } from "./RadioConfig.tsx"; const ConfigPage = () => { const { - workingConfig, - workingModuleConfig, - workingChannelConfig, + getAllConfigChanges, + getAllModuleConfigChanges, + getAllChannelChanges, connection, - removeWorkingConfig, - removeWorkingModuleConfig, - removeWorkingChannelConfig, + clearAllChanges, setConfig, setModuleConfig, addChannel, + getConfigChangeCount, + getModuleConfigChangeCount, + getChannelChangeCount, } = useDevice(); - const [activeConfigSection, setActiveConfigSection] = useState< - "device" | "module" | "channel" - >("device"); const [isSaving, setIsSaving] = useState(false); const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true }); const unsubRef = useRef<(() => void) | null>(null); const [formMethods, setFormMethods] = useState(null); const { toast } = useToast(); - const { t } = useTranslation("deviceConfig"); + const navigate = useNavigate(); + const routerState = useRouterState(); + const { t } = useTranslation("config"); + + const configChangeCount = getConfigChangeCount(); + const moduleConfigChangeCount = getModuleConfigChangeCount(); + const channelChangeCount = getChannelChangeCount(); + + const sections = useMemo( + () => [ + { + key: "radio", + route: radioRoute, + label: t("navigation.radioConfig"), + icon: RadioTowerIcon, + changeCount: configChangeCount, + component: RadioConfig, + }, + { + key: "device", + route: deviceRoute, + label: t("navigation.deviceConfig"), + icon: RouterIcon, + changeCount: moduleConfigChangeCount, + component: DeviceConfig, + }, + { + key: "module", + route: moduleRoute, + label: t("navigation.moduleConfig"), + icon: LayersIcon, + changeCount: channelChangeCount, + component: ModuleConfig, + }, + ], + [t, configChangeCount, moduleConfigChangeCount, channelChangeCount], + ); + + const activeSection = + sections.find((section) => + routerState.location.pathname.includes(`/settings/${section.key}`), + ) ?? sections[0]; const onFormInit = useCallback( (methods: UseFormReturn) => { @@ -69,7 +110,6 @@ const ConfigPage = () => { [], ); - // Cleanup subscription on unmount useEffect(() => { return () => unsubRef.current?.(); }, []); @@ -78,9 +118,12 @@ const ConfigPage = () => { setIsSaving(true); try { - // Save all working channel configs first, doesn't require a commit/reboot + const channelChanges = getAllChannelChanges(); + const configChanges = getAllConfigChanges(); + const moduleConfigChanges = getAllModuleConfigChanges(); + await Promise.all( - workingChannelConfig.map((channel) => + channelChanges.map((channel) => connection?.setChannel(channel).then(() => { toast({ title: t("toast.savedChannel.title", { @@ -93,7 +136,7 @@ const ConfigPage = () => { ); await Promise.all( - workingConfig.map((newConfig) => + configChanges.map((newConfig) => connection?.setConfig(newConfig).then(() => { toast({ title: t("toast.saveSuccess.title"), @@ -106,7 +149,7 @@ const ConfigPage = () => { ); await Promise.all( - workingModuleConfig.map((newModuleConfig) => + moduleConfigChanges.map((newModuleConfig) => connection?.setModuleConfig(newModuleConfig).then(() => toast({ title: t("toast.saveSuccess.title"), @@ -118,23 +161,21 @@ const ConfigPage = () => { ), ); - if (workingConfig.length > 0 || workingModuleConfig.length > 0) { + if (configChanges.length > 0 || moduleConfigChanges.length > 0) { await connection?.commitEditSettings(); } - workingChannelConfig.forEach((newChannel) => { + channelChanges.forEach((newChannel) => { addChannel(newChannel); }); - workingConfig.forEach((newConfig) => { + configChanges.forEach((newConfig) => { setConfig(newConfig); }); - workingModuleConfig.forEach((newModuleConfig) => { + moduleConfigChanges.forEach((newModuleConfig) => { setModuleConfig(newModuleConfig); }); - removeWorkingChannelConfig(); - removeWorkingConfig(); - removeWorkingModuleConfig(); + clearAllChanges(); if (formMethods) { formMethods.reset(formMethods.getValues(), { @@ -144,7 +185,6 @@ const ConfigPage = () => { keepValues: true, }); - // Force RHF to re-validate and emit state formMethods.trigger(); } } catch (_error) { @@ -162,77 +202,49 @@ const ConfigPage = () => { }, [ toast, t, - workingConfig, + getAllConfigChanges, connection, - workingModuleConfig, - workingChannelConfig, + getAllModuleConfigChanges, + getAllChannelChanges, formMethods, addChannel, setConfig, setModuleConfig, - removeWorkingConfig, - removeWorkingModuleConfig, - removeWorkingChannelConfig, + clearAllChanges, ]); const handleReset = useCallback(() => { if (formMethods) { formMethods.reset(); } - removeWorkingChannelConfig(); - removeWorkingConfig(); - removeWorkingModuleConfig(); - }, [ - formMethods, - removeWorkingConfig, - removeWorkingModuleConfig, - removeWorkingChannelConfig, - ]); + clearAllChanges(); + }, [formMethods, clearAllChanges]); const leftSidebar = useMemo( () => ( - setActiveConfigSection("device")} - Icon={SettingsIcon} - isDirty={workingConfig.length > 0} - count={workingConfig.length} - /> - setActiveConfigSection("module")} - Icon={BoxesIcon} - isDirty={workingModuleConfig.length > 0} - count={workingModuleConfig.length} - /> - setActiveConfigSection("channel")} - Icon={LayersIcon} - isDirty={workingChannelConfig.length > 0} - count={workingChannelConfig.length} - /> + {sections.map((section) => ( + navigate({ to: section.route.to })} + Icon={section.icon} + isDirty={section.changeCount > 0} + count={section.changeCount} + /> + ))} ), - [ - activeConfigSection, - workingConfig, - workingModuleConfig, - workingChannelConfig, - t, - ], + [sections, activeSection?.key, navigate, t], ); const hasDrafts = - workingConfig.length > 0 || - workingModuleConfig.length > 0 || - workingChannelConfig.length > 0; + getConfigChangeCount() > 0 || + getModuleConfigChangeCount() > 0 || + getChannelChangeCount() > 0; const hasPending = hasDrafts || rhfState.isDirty; const buttonOpacity = hasPending ? "opacity-100" : "opacity-0"; const saveDisabled = isSaving || !rhfState.isValid || !hasPending; @@ -291,26 +303,16 @@ const ConfigPage = () => { ], ); + const ActiveComponent = activeSection?.component; + return ( - {activeConfigSection === "device" ? ( - - ) : activeConfigSection === "module" ? ( - - ) : ( - - )} + ); }; diff --git a/packages/web/src/routes.tsx b/packages/web/src/routes.tsx index 07125f82..11fb4edc 100644 --- a/packages/web/src/routes.tsx +++ b/packages/web/src/routes.tsx @@ -1,10 +1,10 @@ import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import type { useAppStore, useMessageStore } from "@core/stores"; -import ConfigPage from "@pages/Config/index.tsx"; import { Dashboard } from "@pages/Dashboard/index.tsx"; import MapPage from "@pages/Map/index.tsx"; import MessagesPage from "@pages/Messages.tsx"; import NodesPage from "@pages/Nodes/index.tsx"; +import ConfigPage from "@pages/Settings/index.tsx"; import { createRootRouteWithContext, createRoute, @@ -109,9 +109,33 @@ export const mapWithParamsRoute = createRoute({ // }), }); -const configRoute = createRoute({ +export const settingsRoute = createRoute({ getParentRoute: () => rootRoute, - path: "/config", + path: "/settings", + component: ConfigPage, + // beforeLoad: () => { + // throw redirect({ + // to: "/settings/radio", + // replace: true, + // }); + // }, +}); + +export const radioRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "radio", + component: ConfigPage, +}); + +export const deviceRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "device", + component: ConfigPage, +}); + +export const moduleRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "module", component: ConfigPage, }); @@ -133,7 +157,7 @@ const routeTree = rootRoute.addChildren([ messagesWithParamsRoute, mapRoute, mapWithParamsRoute, - configRoute, + settingsRoute.addChildren([radioRoute, deviceRoute, moduleRoute]), nodesRoute, dialogWithParamsRoute, ]); diff --git a/packages/web/src/tests/setup.ts b/packages/web/src/tests/setup.ts index 1b36f7d8..8d8b72f4 100644 --- a/packages/web/src/tests/setup.ts +++ b/packages/web/src/tests/setup.ts @@ -12,10 +12,10 @@ import commandPaletteEN from "@public/i18n/locales/en/commandPalette.json" with import commonEN from "@public/i18n/locales/en/common.json" with { type: "json", }; -import dashboardEN from "@public/i18n/locales/en/dashboard.json" with { +import configEN from "@public/i18n/locales/en/config.json" with { type: "json", }; -import deviceConfigEN from "@public/i18n/locales/en/deviceConfig.json" with { +import dashboardEN from "@public/i18n/locales/en/dashboard.json" with { type: "json", }; import dialogEN from "@public/i18n/locales/en/dialog.json" with { @@ -53,7 +53,7 @@ const appNamespaces = [ "channels", "commandPalette", "common", - "deviceConfig", + "config", "moduleConfig", "dashboard", "dialog", @@ -76,7 +76,7 @@ i18n.use(initReactI18next).init({ channels: channelsEN, commandPalette: commandPaletteEN, common: commonEN, - deviceConfig: deviceConfigEN, + config: configEN, moduleConfig: moduleConfigEN, dashboard: dashboardEN, dialog: dialogEN, diff --git a/packages/web/src/validation/config/user.ts b/packages/web/src/validation/config/user.ts new file mode 100644 index 00000000..db5109a7 --- /dev/null +++ b/packages/web/src/validation/config/user.ts @@ -0,0 +1,17 @@ +import { t } from "i18next"; +import { z } from "zod/v4"; + +export const UserValidationSchema = z.object({ + longName: z + .string() + .min(1, t("deviceName.validation.longNameMin")) + .max(40, t("deviceName.validation.longNameMax")), + shortName: z + .string() + .min(2, t("deviceName.validation.shortNameMin")) + .max(4, t("deviceName.validation.shortNameMax")), + isUnmessageable: z.boolean().default(false), + isLicensed: z.boolean().default(false), +}); + +export type UserValidation = z.infer;