From 829b1bb3965d6cb3296ef7281f1364ce38b60799 Mon Sep 17 00:00:00 2001 From: philon- Date: Sun, 24 Aug 2025 01:30:02 +0200 Subject: [PATCH 1/5] Channel config rework Add staged channel config with tabbed UI, import/export workflow, and global form state refactor --- .../web/public/i18n/locales/en/channels.json | 4 +- .../web/public/i18n/locales/en/dialog.json | 12 +- packages/web/public/i18n/locales/en/ui.json | 7 +- .../src/components/Dialog/ImportDialog.tsx | 142 ++++++++++---- .../web/src/components/Dialog/QRDialog.tsx | 2 +- .../web/src/components/Form/DynamicForm.tsx | 31 ++- .../components/Form/FormPasswordGenerator.tsx | 20 +- .../web/src/components/Form/FormSelect.tsx | 3 + .../{ => ChannelConfig}/Channel.tsx | 176 +++++++++++------- .../PageComponents/Config/Bluetooth.tsx | 1 - .../PageComponents/Config/Device/index.tsx | 1 - .../PageComponents/Config/Display.tsx | 1 - .../components/PageComponents/Config/LoRa.tsx | 1 - .../PageComponents/Config/Network/index.tsx | 1 - .../PageComponents/Config/Position.tsx | 1 - .../PageComponents/Config/Power.tsx | 1 - .../Config/Security/Security.tsx | 49 +++-- .../ModuleConfig/AmbientLighting.tsx | 1 - .../PageComponents/ModuleConfig/Audio.tsx | 1 - .../ModuleConfig/CannedMessage.tsx | 1 - .../ModuleConfig/DetectionSensor.tsx | 1 - .../ModuleConfig/ExternalNotification.tsx | 1 - .../PageComponents/ModuleConfig/MQTT.tsx | 1 - .../ModuleConfig/NeighborInfo.tsx | 1 - .../ModuleConfig/Paxcounter.tsx | 1 - .../PageComponents/ModuleConfig/RangeTest.tsx | 1 - .../PageComponents/ModuleConfig/Serial.tsx | 1 - .../ModuleConfig/StoreForward.tsx | 1 - .../PageComponents/ModuleConfig/Telemetry.tsx | 1 - packages/web/src/components/Sidebar.tsx | 6 - packages/web/src/components/UI/Generator.tsx | 20 -- .../web/src/core/stores/appStore/index.ts | 64 ++----- .../stores/deviceStore/deviceStore.mock.ts | 4 + .../web/src/core/stores/deviceStore/index.ts | 62 ++++++ packages/web/src/pages/Channels.tsx | 86 --------- .../web/src/pages/Config/ChannelConfig.tsx | 95 ++++++++++ .../web/src/pages/Config/DeviceConfig.tsx | 6 +- .../web/src/pages/Config/ModuleConfig.tsx | 2 +- packages/web/src/pages/Config/index.tsx | 137 +++++++++----- packages/web/src/pages/Messages.tsx | 2 +- packages/web/src/routes.tsx | 8 - packages/web/src/validation/channel.ts | 3 + 42 files changed, 555 insertions(+), 405 deletions(-) rename packages/web/src/components/PageComponents/{ => ChannelConfig}/Channel.tsx (63%) delete mode 100644 packages/web/src/pages/Channels.tsx create mode 100644 packages/web/src/pages/Config/ChannelConfig.tsx diff --git a/packages/web/public/i18n/locales/en/channels.json b/packages/web/public/i18n/locales/en/channels.json index 95f64b16..e1be43bf 100644 --- a/packages/web/public/i18n/locales/en/channels.json +++ b/packages/web/public/i18n/locales/en/channels.json @@ -3,7 +3,9 @@ "sectionLabel": "Channels", "channelName": "Channel: {{channelName}}", "broadcastLabel": "Primary", - "channelIndex": "Ch {{index}}" + "channelIndex": "Ch {{index}}", + "import": "Import", + "export": "Export" }, "validation": { "pskInvalid": "Please enter a valid {{bits}} bit PSK." diff --git a/packages/web/public/i18n/locales/en/dialog.json b/packages/web/public/i18n/locales/en/dialog.json index c954f0d3..be3708cd 100644 --- a/packages/web/public/i18n/locales/en/dialog.json +++ b/packages/web/public/i18n/locales/en/dialog.json @@ -16,14 +16,18 @@ } }, "import": { - "description": "The current LoRa configuration will be overridden.", + "description": "Import a Channel Set from a Meshtastic URL.", "error": { "invalidUrl": "Invalid Meshtastic URL" }, - "channelPrefix": "Channel: ", + "channelPrefix": "Channel ", + "primary": "Primary ", + "doNotImport": "No not import", + "channelName": "Name", + "channelSlot": "Slot", "channelSetUrl": "Channel Set/QR Code URL", - "channels": "Channels:", - "usePreset": "Use Preset?", + "usePreset": "Import LoRa Preset", + "presetDescription": "The current LoRa configuration will be overridden.", "title": "Import Channel Set" }, "locationResponse": { diff --git a/packages/web/public/i18n/locales/en/ui.json b/packages/web/public/i18n/locales/en/ui.json index 6b866503..4700c5ad 100644 --- a/packages/web/public/i18n/locales/en/ui.json +++ b/packages/web/public/i18n/locales/en/ui.json @@ -4,9 +4,10 @@ "messages": "Messages", "map": "Map", "config": "Config", + "channels": "Channels", "radioConfig": "Radio Config", "moduleConfig": "Module Config", - "channels": "Channels", + "channelConfig": "Channel Config", "nodes": "Nodes" }, "app": { @@ -67,6 +68,10 @@ "title": "Saving Config", "description": "The configuration change {{case}} has been saved." }, + "saveAllSuccess": { + "title": "Save complete", + "description": "The configuration changes have been saved." + }, "favoriteNode": { "title": "{{action}} {{nodeName}} {{direction}} favorites.", "action": { diff --git a/packages/web/src/components/Dialog/ImportDialog.tsx b/packages/web/src/components/Dialog/ImportDialog.tsx index ed4192c1..339dd733 100644 --- a/packages/web/src/components/Dialog/ImportDialog.tsx +++ b/packages/web/src/components/Dialog/ImportDialog.tsx @@ -11,13 +11,19 @@ import { } from "@components/UI/Dialog.tsx"; import { Input } from "@components/UI/Input.tsx"; import { Label } from "@components/UI/Label.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/UI/Select.tsx"; import { Switch } from "@components/UI/Switch.tsx"; import { useDevice } from "@core/stores"; import { Protobuf } from "@meshtastic/core"; import { toByteArray } from "base64-js"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Checkbox } from "../UI/Checkbox/index.tsx"; export interface ImportDialogProps { open: boolean; @@ -26,12 +32,15 @@ export interface ImportDialogProps { } export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { + const { config } = useDevice(); const { t } = useTranslation("dialog"); const [importDialogInput, setImportDialogInput] = useState(""); const [channelSet, setChannelSet] = useState(); const [validUrl, setValidUrl] = useState(false); + const [updateConfig, setUpdateConfig] = useState(true); + const [importIndex, setImportIndex] = useState([]); - const { connection } = useDevice(); + const { setWorkingChannelConfig, setWorkingConfig } = useDevice(); useEffect(() => { // the channel information is contained in the URL's fragment, which will be present after a @@ -55,12 +64,17 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { ) .replace(/-/g, "+") .replace(/_/g, "/"); - setChannelSet( - fromBinary( - Protobuf.AppOnly.ChannelSetSchema, - toByteArray(paddedString), - ), + + const newChannelSet = fromBinary( + Protobuf.AppOnly.ChannelSetSchema, + toByteArray(paddedString), ); + + const newImportChannelArray = newChannelSet.settings.map((_, idx) => idx); + + setChannelSet(newChannelSet); + setImportIndex(newImportChannelArray); + setUpdateConfig(newChannelSet?.loraConfig !== undefined); setValidUrl(true); } catch (_error) { setValidUrl(false); @@ -71,11 +85,15 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { const apply = () => { channelSet?.settings.map( (ch: Protobuf.Channel.ChannelSettings, index: number) => { - connection?.setChannel( + if (importIndex[index] === -1) { + return; + } + + setWorkingChannelConfig( create(Protobuf.Channel.ChannelSchema, { - index, + index: importIndex[index], role: - index === 0 + importIndex[index] === 0 ? Protobuf.Channel.Channel_Role.PRIMARY : Protobuf.Channel.Channel_Role.SECONDARY, settings: ch, @@ -84,16 +102,33 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { }, ); - if (channelSet?.loraConfig) { - connection?.setConfig( + if (channelSet?.loraConfig && updateConfig) { + setWorkingConfig( create(Protobuf.Config.ConfigSchema, { payloadVariant: { case: "lora", - value: channelSet.loraConfig, + value: { + ...config.lora, + ...channelSet.loraConfig, + }, }, }), ); } + // Reset state after import + setImportDialogInput(""); + setChannelSet(undefined); + setValidUrl(false); + setImportIndex([]); + setUpdateConfig(true); + + onOpenChange(false); + }; + + const onSelectChange = (value: string, index: number) => { + const newImportIndex = [...importIndex]; + newImportIndex[index] = Number.parseInt(value); + setImportIndex(newImportIndex); }; return ( @@ -108,49 +143,74 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { { setImportDialogInput(e.target.value); }} /> {validUrl && ( -
+
-
- +
setUpdateConfig(next)} /> +
- {/* */}
- {/* */} - - {t("import.channels")} - -
- {channelSet?.settings.map((channel) => ( -
- { fields: FieldProps[]; }[]; validationSchema?: ZodType; - formId?: string; } export type DynamicFormFormInit = ( @@ -83,10 +81,9 @@ export function DynamicForm({ values, fieldGroups, validationSchema, - formId, }: DynamicFormProps) { const { t } = useTranslation(); - const { addError, removeError } = useAppStore(); + const { setDirtyForm, setValidForm } = useAppStore(); const internalMethods = useForm({ mode: "onChange", @@ -111,21 +108,17 @@ export function DynamicForm({ }, [onFormInit, propMethods, internalMethods]); useEffect(() => { - const errorKeys = Object.keys(formState.errors); - if (formId) { - if (errorKeys.length === 0) { - dotPaths(getValues()).forEach((key) => { - removeError(key); - }); - removeError(formId); - } else { - errorKeys.forEach((key) => { - addError(key, ""); - }); - addError(formId, ""); - } - } - }, [formState.errors, addError, formId, getValues, removeError]); + setValidForm(formState.isValid); + setDirtyForm(formState.isDirty); + }, [formState.isDirty, formState.isValid, setValidForm, setDirtyForm]); + + useEffect( + () => () => { + setValidForm(true); + setDirtyForm(false); + }, + [setValidForm, setDirtyForm], + ); const isDisabled = ( disabledBy?: DisabledBy[], diff --git a/packages/web/src/components/Form/FormPasswordGenerator.tsx b/packages/web/src/components/Form/FormPasswordGenerator.tsx index d4543e74..a56e4d39 100644 --- a/packages/web/src/components/Form/FormPasswordGenerator.tsx +++ b/packages/web/src/components/Form/FormPasswordGenerator.tsx @@ -4,8 +4,7 @@ import type { } from "@components/Form/DynamicForm.tsx"; import { Generator } from "@components/UI/Generator.tsx"; import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts"; -import { useEffect } from "react"; -import { Controller, type FieldValues, useFormContext } from "react-hook-form"; +import { Controller, type FieldValues } from "react-hook-form"; import type { ButtonVariant } from "../UI/Button.tsx"; export interface PasswordGeneratorProps extends BaseFormBuilderProps { @@ -34,36 +33,29 @@ export function PasswordGenerator({ invalid, }: GenericFormElementProps>) { const { isVisible } = usePasswordVisibilityToggle(); - const { trigger } = useFormContext(); - - useEffect(() => { - trigger(field.name); - }, [field.name, trigger]); return ( ( + render={({ field: controllerField }) => ( { - if (field.inputChange) { - field.inputChange(e); - } - onChange(e); + const value = e.target.value; + controllerField.onChange(value); // ensure RHF receives just the value + field.inputChange?.(e); // call any external handler }} selectChange={field.selectChange ?? (() => {})} - value={value} variant={invalid ? "invalid" : isDirty ? "dirty" : "default"} actionButtons={field.actionButtons} showPasswordToggle={field.showPasswordToggle} showCopyButton={field.showCopyButton} {...field.properties} - {...rest} + {...controllerField} disabled={disabled} /> )} diff --git a/packages/web/src/components/Form/FormSelect.tsx b/packages/web/src/components/Form/FormSelect.tsx index 4b5638be..f92b0c15 100644 --- a/packages/web/src/components/Form/FormSelect.tsx +++ b/packages/web/src/components/Form/FormSelect.tsx @@ -70,6 +70,9 @@ export function SelectInput({ const handleValueChange = async (newValue: string) => { const selectedKey = valueToKeyMap[newValue]; + if (!selectedKey) { + return; + } if (field.validate) { const isValid = await field.validate(selectedKey); diff --git a/packages/web/src/components/PageComponents/Channel.tsx b/packages/web/src/components/PageComponents/ChannelConfig/Channel.tsx similarity index 63% rename from packages/web/src/components/PageComponents/Channel.tsx rename to packages/web/src/components/PageComponents/ChannelConfig/Channel.tsx index 409ee7b2..20ae370f 100644 --- a/packages/web/src/components/PageComponents/Channel.tsx +++ b/packages/web/src/components/PageComponents/ChannelConfig/Channel.tsx @@ -1,118 +1,160 @@ -import { makeChannelSchema } from "@app/validation/channel.ts"; +import { + type ChannelValidation, + makeChannelSchema, +} from "@app/validation/channel.ts"; import { create } from "@bufbuild/protobuf"; import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"; -import { DynamicForm } from "@components/Form/DynamicForm.tsx"; -import { useToast } from "@core/hooks/useToast.ts"; +import { createZodResolver } from "@components/Form/createZodResolver.ts"; +import { + DynamicForm, + type DynamicFormFormInit, +} from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores"; +import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { Protobuf } from "@meshtastic/core"; import { fromByteArray, toByteArray } from "base64-js"; import cryptoRandomString from "crypto-random-string"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { type DefaultValues, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import type { infer as zodInfer } from "zod/v4"; export interface SettingsPanelProps { + onFormInit: DynamicFormFormInit; channel: Protobuf.Channel.Channel; } -export const Channel = ({ channel }: SettingsPanelProps) => { - const { config, connection, addChannel } = useDevice(); +export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => { + const { + config, + setWorkingChannelConfig, + getWorkingChannelConfig, + removeWorkingChannelConfig, + } = useDevice(); const { t } = useTranslation(["channels", "ui", "dialog"]); - const { toast } = useToast(); + + const defaultConfig = channel; + const defaultValues = { + ...defaultConfig, + ...{ + settings: { + ...defaultConfig?.settings, + psk: fromByteArray(defaultConfig?.settings?.psk ?? new Uint8Array(0)), + moduleSettings: { + ...defaultConfig?.settings?.moduleSettings, + positionPrecision: + defaultConfig?.settings?.moduleSettings?.positionPrecision === + undefined + ? 10 + : defaultConfig?.settings?.moduleSettings?.positionPrecision, + }, + }, + }, + }; + + const effectiveConfig = getWorkingChannelConfig(channel.index) ?? channel; + const formValues = { + ...effectiveConfig, + ...{ + settings: { + ...effectiveConfig?.settings, + psk: fromByteArray(effectiveConfig?.settings?.psk ?? new Uint8Array(0)), + moduleSettings: { + ...effectiveConfig?.settings?.moduleSettings, + positionPrecision: + effectiveConfig?.settings?.moduleSettings?.positionPrecision === + undefined + ? 10 + : effectiveConfig?.settings?.moduleSettings?.positionPrecision, + }, + }, + }, + }; const [preSharedDialogOpen, setPreSharedDialogOpen] = useState(false); - const [pass, setPass] = useState( - fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), - ); const [byteCount, setBytes] = useState( - channel?.settings?.psk.length ?? 16, + effectiveConfig?.settings?.psk.length ?? 16, ); - const ChannelValidationSchema = useMemo(() => { return makeChannelSchema(byteCount); }, [byteCount]); - type ChannelValidation = zodInfer; + const formMethods = useForm({ + mode: "onChange", + defaultValues: defaultValues as DefaultValues, + resolver: createZodResolver(ChannelValidationSchema), + shouldFocusError: false, + resetOptions: { keepDefaultValues: true }, + values: formValues as ChannelValidation, + }); + const { setValue, trigger, handleSubmit, formState } = formMethods; + + useEffect(() => { + onFormInit?.(formMethods); + }, [onFormInit, formMethods]); + + // Since byteCount is an independent state, we need to use the effective value + // from the channel config to ensure the form updates when the setting changes + const effectiveByteCount = effectiveConfig.settings?.psk.length ?? 16; + useEffect(() => { + setBytes(effectiveByteCount); + trigger("settings.psk"); + }, [effectiveByteCount, trigger]); const onSubmit = (data: ChannelValidation) => { - const channel = create(Protobuf.Channel.ChannelSchema, { + if (!formState.isReady) { + return; + } + + const payload = { ...data, settings: { ...data.settings, - psk: toByteArray(pass), + psk: toByteArray(data.settings.psk), moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, { ...data.settings.moduleSettings, positionPrecision: data.settings.moduleSettings.positionPrecision, }), }, - }); - connection?.setChannel(channel).then(() => { - console.debug( - t("toast.savedChannel.title", { - ns: "ui", - channelName: channel.settings?.name, - }), - ); - toast({ - title: t("toast.savedChannel.title", { - ns: "ui", - channelName: channel.settings?.name, - }), - }); - addChannel(channel); - }); + }; + + if (deepCompareConfig(channel, payload, true)) { + removeWorkingChannelConfig(channel.index); + return; + } + + setWorkingChannelConfig(create(Protobuf.Channel.ChannelSchema, payload)); }; - const preSharedKeyRegenerate = () => { + const preSharedKeyRegenerate = async () => { const newPsk = btoa( cryptoRandomString({ - length: byteCount ?? 0, + length: byteCount ?? 16, type: "alphanumeric", }), ); - setPass(newPsk); - + setValue("settings.psk", newPsk, { shouldDirty: true }); setPreSharedDialogOpen(false); - }; - - const preSharedClickEvent = () => { - setPreSharedDialogOpen(true); - }; - const inputChangeEvent = (e: React.ChangeEvent) => { - setPass(e.currentTarget?.value); + const valid = await trigger("settings.psk"); + if (valid) { + handleSubmit(onSubmit)(); // manually invoke form submit + } }; const selectChangeEvent = (e: string) => { const count = Number.parseInt(e); - setBytes(count); + if (!Number.isNaN(count)) { + setBytes(count); + trigger("settings.psk"); + } }; return ( <> + propMethods={formMethods} onSubmit={onSubmit} - submitType="onSubmit" - validationSchema={ChannelValidationSchema} - hasSubmitButton - defaultValues={{ - ...channel, - ...{ - settings: { - ...channel?.settings, - psk: pass, - moduleSettings: { - ...channel?.settings?.moduleSettings, - positionPrecision: - channel?.settings?.moduleSettings?.positionPrecision === - undefined - ? 10 - : channel?.settings?.moduleSettings?.positionPrecision, - }, - }, - }, - }} fieldGroups={[ { label: t("settings.label"), @@ -140,19 +182,17 @@ export const Channel = ({ channel }: SettingsPanelProps) => { id: "channel-psk", label: t("psk.label"), description: t("psk.description"), - devicePSKBitCount: byteCount ?? 0, - inputChange: inputChangeEvent, + devicePSKBitCount: byteCount ?? 16, selectChange: selectChangeEvent, actionButtons: [ { text: t("psk.generate"), variant: "success", - onClick: preSharedClickEvent, + onClick: () => setPreSharedDialogOpen(true), }, ], hide: true, properties: { - value: pass, showPasswordToggle: true, showCopyButton: true, }, diff --git a/packages/web/src/components/PageComponents/Config/Bluetooth.tsx b/packages/web/src/components/PageComponents/Config/Bluetooth.tsx index 2d4bad3d..ee283cdc 100644 --- a/packages/web/src/components/PageComponents/Config/Bluetooth.tsx +++ b/packages/web/src/components/PageComponents/Config/Bluetooth.tsx @@ -44,7 +44,6 @@ export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={BluetoothValidationSchema} - formId="Config_BluetoothConfig" defaultValues={config.bluetooth} values={getEffectiveConfig("bluetooth")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/Config/Device/index.tsx b/packages/web/src/components/PageComponents/Config/Device/index.tsx index 641e8b5d..96afeeed 100644 --- a/packages/web/src/components/PageComponents/Config/Device/index.tsx +++ b/packages/web/src/components/PageComponents/Config/Device/index.tsx @@ -46,7 +46,6 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={DeviceValidationSchema} - formId="Config_DeviceConfig" defaultValues={config.device} values={getEffectiveConfig("device")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/Config/Display.tsx b/packages/web/src/components/PageComponents/Config/Display.tsx index f328f0f5..f8e1ce36 100644 --- a/packages/web/src/components/PageComponents/Config/Display.tsx +++ b/packages/web/src/components/PageComponents/Config/Display.tsx @@ -43,7 +43,6 @@ export const Display = ({ onFormInit }: DisplayConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={DisplayValidationSchema} - formId="Config_DisplayConfig" defaultValues={config.display} values={getEffectiveConfig("display")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/Config/LoRa.tsx b/packages/web/src/components/PageComponents/Config/LoRa.tsx index 64029751..cfa7c612 100644 --- a/packages/web/src/components/PageComponents/Config/LoRa.tsx +++ b/packages/web/src/components/PageComponents/Config/LoRa.tsx @@ -44,7 +44,6 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={LoRaValidationSchema} - formId="Config_LoRaConfig" defaultValues={config.lora} values={getEffectiveConfig("lora")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/Config/Network/index.tsx b/packages/web/src/components/PageComponents/Config/Network/index.tsx index a1008865..b999d5dc 100644 --- a/packages/web/src/components/PageComponents/Config/Network/index.tsx +++ b/packages/web/src/components/PageComponents/Config/Network/index.tsx @@ -62,7 +62,6 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={NetworkValidationSchema} - formId="Config_NetworkConfig" defaultValues={{ ...config.network, ipv4Config: { diff --git a/packages/web/src/components/PageComponents/Config/Position.tsx b/packages/web/src/components/PageComponents/Config/Position.tsx index 4d75411b..c52bc668 100644 --- a/packages/web/src/components/PageComponents/Config/Position.tsx +++ b/packages/web/src/components/PageComponents/Config/Position.tsx @@ -62,7 +62,6 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { }} onFormInit={onFormInit} validationSchema={PositionValidationSchema} - formId="Config_PositionConfig" defaultValues={config.position} values={getEffectiveConfig("position")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/Config/Power.tsx b/packages/web/src/components/PageComponents/Config/Power.tsx index 0df07032..323c1ec2 100644 --- a/packages/web/src/components/PageComponents/Config/Power.tsx +++ b/packages/web/src/components/PageComponents/Config/Power.tsx @@ -44,7 +44,6 @@ export const Power = ({ onFormInit }: PowerConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={PowerValidationSchema} - formId="Config_PowerConfig" defaultValues={config.power} values={getEffectiveConfig("power")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/Config/Security/Security.tsx b/packages/web/src/components/PageComponents/Config/Security/Security.tsx index 404f5675..21d620ea 100644 --- a/packages/web/src/components/PageComponents/Config/Security/Security.tsx +++ b/packages/web/src/components/PageComponents/Config/Security/Security.tsx @@ -12,7 +12,7 @@ import { DynamicForm, type DynamicFormFormInit, } from "@components/Form/DynamicForm.tsx"; -import { useAppStore, useDevice } from "@core/stores"; +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"; @@ -35,21 +35,34 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { removeWorkingConfig, } = useDevice(); - const { removeError } = useAppStore(); const { t } = useTranslation("deviceConfig"); - const securityConfig = getEffectiveConfig("security"); + const defaultConfig = config.security; const defaultValues = { - ...securityConfig, + ...defaultConfig, + ...{ + privateKey: fromByteArray(defaultConfig?.privateKey ?? new Uint8Array(0)), + publicKey: fromByteArray(defaultConfig?.publicKey ?? new Uint8Array(0)), + adminKey: [ + fromByteArray(defaultConfig?.adminKey?.at(0) ?? new Uint8Array(0)), + fromByteArray(defaultConfig?.adminKey?.at(1) ?? new Uint8Array(0)), + fromByteArray(defaultConfig?.adminKey?.at(2) ?? new Uint8Array(0)), + ], + }, + }; + + const effectiveConfig = getEffectiveConfig("security"); + const formValues = { + ...effectiveConfig, ...{ privateKey: fromByteArray( - securityConfig?.privateKey ?? new Uint8Array(0), + effectiveConfig?.privateKey ?? new Uint8Array(0), ), - publicKey: fromByteArray(securityConfig?.publicKey ?? new Uint8Array(0)), + publicKey: fromByteArray(effectiveConfig?.publicKey ?? new Uint8Array(0)), adminKey: [ - fromByteArray(securityConfig?.adminKey?.at(0) ?? new Uint8Array(0)), - fromByteArray(securityConfig?.adminKey?.at(1) ?? new Uint8Array(0)), - fromByteArray(securityConfig?.adminKey?.at(2) ?? new Uint8Array(0)), + fromByteArray(effectiveConfig?.adminKey?.at(0) ?? new Uint8Array(0)), + fromByteArray(effectiveConfig?.adminKey?.at(1) ?? new Uint8Array(0)), + fromByteArray(effectiveConfig?.adminKey?.at(2) ?? new Uint8Array(0)), ], }, }; @@ -60,8 +73,9 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { resolver: createZodResolver(RawSecuritySchema), shouldFocusError: false, resetOptions: { keepDefaultValues: true }, + values: formValues as RawSecurity, }); - const { setValue, formState } = formMethods; + const { setValue, trigger, handleSubmit, formState } = formMethods; useEffect(() => { onFormInit?.(formMethods); @@ -108,19 +122,21 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { updatePublicKey(fromByteArray(privateKey)); }; - const updatePublicKey = (privateKey: string) => { + const updatePublicKey = async (privateKey: string) => { try { const publicKey = fromByteArray( getX25519PublicKey(toByteArray(privateKey)), ); - setValue("privateKey", privateKey); - setValue("publicKey", publicKey); + setValue("privateKey", privateKey, { shouldDirty: true }); + setValue("publicKey", publicKey, { shouldDirty: true }); - removeError("privateKey"); - removeError("publicKey"); setPrivateKeyDialogOpen(false); } catch (_e) { - setValue("privateKey", privateKey); + setValue("privateKey", privateKey, { shouldDirty: true }); + } + const valid = await trigger(["privateKey", "publicKey"]); + if (valid) { + handleSubmit(onSubmit)(); // manually invoke form submit } }; @@ -137,7 +153,6 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { propMethods={formMethods} onSubmit={onSubmit} - formId="Config_SecurityConfig" fieldGroups={[ { label: t("security.title"), diff --git a/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx b/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx index a481ac0f..5ae258dd 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx @@ -50,7 +50,6 @@ export const AmbientLighting = ({ onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={AmbientLightingValidationSchema} - formId="ModuleConfig_AmbientLightingConfig" defaultValues={moduleConfig.ambientLighting} values={getEffectiveModuleConfig("ambientLighting")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx index aa76c897..eb7068f2 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/Audio.tsx @@ -48,7 +48,6 @@ export const Audio = ({ onFormInit }: AudioModuleConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={AudioValidationSchema} - formId="ModuleConfig_AudioConfig" defaultValues={moduleConfig.audio} values={getEffectiveModuleConfig("audio")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx b/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx index 05887155..711c1657 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/CannedMessage.tsx @@ -51,7 +51,6 @@ export const CannedMessage = ({ onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={CannedMessageValidationSchema} - formId="ModuleConfig_CannedMessageConfig" defaultValues={moduleConfig.cannedMessage} values={getEffectiveModuleConfig("cannedMessage")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx b/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx index 9745430b..5d041884 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx @@ -51,7 +51,6 @@ export const DetectionSensor = ({ onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={DetectionSensorValidationSchema} - formId="ModuleConfig_DetectionSensorConfig" defaultValues={moduleConfig.detectionSensor} values={getEffectiveModuleConfig("detectionSensor")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx b/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx index 3e616719..9bbb6178 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx @@ -51,7 +51,6 @@ export const ExternalNotification = ({ onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={ExternalNotificationValidationSchema} - formId="ModuleConfig_ExternalNotificationConfig" defaultValues={moduleConfig.externalNotification} values={getEffectiveModuleConfig("externalNotification")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx b/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx index a1750bd6..c505a34a 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/MQTT.tsx @@ -72,7 +72,6 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={MqttValidationSchema} - formId="ModuleConfig_MqttConfig" defaultValues={populateDefaultValues(moduleConfig.mqtt)} values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx b/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx index efbe4738..2e4f189f 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx @@ -49,7 +49,6 @@ export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={NeighborInfoValidationSchema} - formId="ModuleConfig_NeighborInfoConfig" defaultValues={moduleConfig.neighborInfo} values={getEffectiveModuleConfig("neighborInfo")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx index 875e5560..d878c32a 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/Paxcounter.tsx @@ -49,7 +49,6 @@ export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={PaxcounterValidationSchema} - formId="ModuleConfig_PaxcounterConfig" defaultValues={moduleConfig.paxcounter} values={getEffectiveModuleConfig("paxcounter")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx b/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx index 566c91cd..73c08fab 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/RangeTest.tsx @@ -50,7 +50,6 @@ export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={RangeTestValidationSchema} - formId="ModuleConfig_RangeTestConfig" defaultValues={moduleConfig.rangeTest} values={getEffectiveModuleConfig("rangeTest")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx index 824854c6..70843430 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/Serial.tsx @@ -49,7 +49,6 @@ export const Serial = ({ onFormInit }: SerialModuleConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={SerialValidationSchema} - formId="ModuleConfig_SerialConfig" defaultValues={moduleConfig.serial} values={getEffectiveModuleConfig("serial")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx b/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx index 48826ebc..eebb84e7 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/StoreForward.tsx @@ -49,7 +49,6 @@ export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={StoreForwardValidationSchema} - formId="ModuleConfig_StoreForwardConfig" defaultValues={moduleConfig.storeForward} values={getEffectiveModuleConfig("storeForward")} fieldGroups={[ diff --git a/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx b/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx index a007fb80..782c5022 100644 --- a/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx +++ b/packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx @@ -49,7 +49,6 @@ export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => { onSubmit={onSubmit} onFormInit={onFormInit} validationSchema={TelemetryValidationSchema} - formId="ModuleConfig_TelemetryConfig" defaultValues={moduleConfig.telemetry} values={getEffectiveModuleConfig("telemetry")} fieldGroups={[ diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx index f86de173..dfd32c1f 100644 --- a/packages/web/src/components/Sidebar.tsx +++ b/packages/web/src/components/Sidebar.tsx @@ -7,7 +7,6 @@ import { cn } from "@core/utils/cn.ts"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { CircleChevronLeft, - LayersIcon, type LucideIcon, MapIcon, MessageSquareIcon, @@ -113,11 +112,6 @@ export const Sidebar = ({ children }: SidebarProps) => { icon: SettingsIcon, page: "config", }, - { - name: t("navigation.channels"), - icon: LayersIcon, - page: "channels", - }, { name: `${t("navigation.nodes")} (${displayedNodeCount})`, icon: UsersIcon, diff --git a/packages/web/src/components/UI/Generator.tsx b/packages/web/src/components/UI/Generator.tsx index d1dd773a..52cd4b96 100644 --- a/packages/web/src/components/UI/Generator.tsx +++ b/packages/web/src/components/UI/Generator.tsx @@ -7,7 +7,6 @@ import { SelectTrigger, SelectValue, } from "@components/UI/Select.tsx"; -import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; export interface ActionButton { @@ -48,7 +47,6 @@ const Generator = ({ showCopyButton, ...props }: GeneratorProps) => { - const inputRef = useRef(null); const { t } = useTranslation(); const passwordRequiredBitSize = bits @@ -76,23 +74,6 @@ const Generator = ({ }, ]; - // Invokes onChange event on the input element when the value changes from the parent component - useEffect(() => { - if (!inputRef.current) { - return; - } - const setValue = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - "value", - )?.set; - - if (!setValue) { - return; - } - inputRef.current.value = ""; - setValue.call(inputRef.current, value); - inputRef.current.dispatchEvent(new Event("input", { bubbles: true })); - }, [value]); return ( <> diff --git a/packages/web/src/core/stores/appStore/index.ts b/packages/web/src/core/stores/appStore/index.ts index bdf2d67f..0f6eb039 100644 --- a/packages/web/src/core/stores/appStore/index.ts +++ b/packages/web/src/core/stores/appStore/index.ts @@ -8,11 +8,6 @@ export interface RasterSource { tileSize: number; } -interface ErrorState { - field: string; - message: string; -} - interface AppState { selectedDevice: number; devices: { @@ -24,7 +19,8 @@ interface AppState { nodeNumToBeRemoved: number; connectDialogOpen: boolean; nodeNumDetails: number; - errors: ErrorState[]; + validForm: boolean; + dirtyForm: boolean; setRasterSources: (sources: RasterSource[]) => void; addRasterSource: (source: RasterSource) => void; @@ -38,13 +34,11 @@ interface AppState { setNodeNumDetails: (nodeNum: number) => void; // Error management - hasErrors: () => boolean; - getErrorMessage: (field: string) => string | undefined; - hasFieldError: (field: string) => boolean; - addError: (field: string, message: string) => void; - removeError: (field: string) => void; - clearErrors: () => void; - setNewErrors: (newErrors: ErrorState[]) => void; + isValidForm: () => boolean; + setValidForm: (valid: boolean) => void; + + isDirtyForm: () => boolean; + setDirtyForm: (unsaved: boolean) => void; } export const useAppStore = create()((set, get) => ({ @@ -56,7 +50,8 @@ export const useAppStore = create()((set, get) => ({ connectDialogOpen: false, nodeNumToBeRemoved: 0, nodeNumDetails: 0, - errors: [], + validForm: true, + dirtyForm: true, setRasterSources: (sources: RasterSource[]) => { set( @@ -114,46 +109,27 @@ export const useAppStore = create()((set, get) => ({ set(() => ({ nodeNumDetails: nodeNum, })), - hasErrors: () => { - const state = get(); - return state.errors.length > 0; - }, - getErrorMessage: (field: string) => { - const state = get(); - return state.errors.find((err) => err.field === field)?.message; - }, - hasFieldError: (field: string) => { + + isValidForm: () => { const state = get(); - return state.errors.some((err) => err.field === field); + return state.validForm; }, - addError: (field: string, message: string) => { + setValidForm: (valid: boolean) => { set( produce((draft) => { - draft.errors = [ - ...draft.errors.filter((e) => e.field !== field), - { field, message }, - ]; + draft.validForm = valid; }), ); }, - removeError: (field: string) => { - set( - produce((draft) => { - draft.errors = draft.errors.filter((e) => e.field !== field); - }), - ); - }, - clearErrors: () => { - set( - produce((draft) => { - draft.errors = []; - }), - ); + + isDirtyForm: () => { + const state = get(); + return state.dirtyForm; }, - setNewErrors: (newErrors: ErrorState[]) => { + setDirtyForm: (saved: boolean) => { set( produce((draft) => { - draft.errors = newErrors; + draft.dirtyForm = saved; }), ); }, diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts index 63114812..325cd8c2 100644 --- a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts +++ b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts @@ -20,6 +20,7 @@ export const mockDeviceStore: Device = { moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig, workingConfig: [], workingModuleConfig: [], + workingChannelConfig: [], hardware: {} as Protobuf.Mesh.MyNodeInfo, metadata: new Map(), traceroutes: new Map(), @@ -59,6 +60,9 @@ export const mockDeviceStore: Device = { 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(), diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts index 9ee04008..ddf420ef 100644 --- a/packages/web/src/core/stores/deviceStore/index.ts +++ b/packages/web/src/core/stores/deviceStore/index.ts @@ -35,6 +35,7 @@ export interface Device { moduleConfig: Protobuf.LocalOnly.LocalModuleConfig; workingConfig: Protobuf.Config.Config[]; workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[]; + workingChannelConfig: Protobuf.Channel.Channel[]; hardware: Protobuf.Mesh.MyNodeInfo; metadata: Map; traceroutes: Map< @@ -92,6 +93,11 @@ export interface Device { 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; @@ -163,6 +169,7 @@ export const useDeviceStore = createStore((set, get) => ({ moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema), workingConfig: [], workingModuleConfig: [], + workingChannelConfig: [], hardware: create(Protobuf.Mesh.MyNodeInfoSchema), metadata: new Map(), traceroutes: new Map(), @@ -469,6 +476,61 @@ export const useDeviceStore = createStore((set, get) => ({ }; }, + 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) => { diff --git a/packages/web/src/pages/Channels.tsx b/packages/web/src/pages/Channels.tsx deleted file mode 100644 index 634ed190..00000000 --- a/packages/web/src/pages/Channels.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Channel } from "@components/PageComponents/Channel.tsx"; -import { PageLayout } from "@components/PageLayout.tsx"; -import { Sidebar } from "@components/Sidebar.tsx"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@components/UI/Tabs.tsx"; -import { useDevice } from "@core/stores"; -import type { Protobuf } from "@meshtastic/core"; -import { Types } from "@meshtastic/core"; -import i18next from "i18next"; -import { QrCodeIcon, UploadIcon } from "lucide-react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; - -export const getChannelName = (channel: Protobuf.Channel.Channel) => { - return channel.settings?.name.length - ? channel.settings?.name - : channel.index === 0 - ? i18next.t("page.broadcastLabel") - : i18next.t("page.channelIndex", { - ns: "channels", - index: channel.index, - }); -}; - -const ChannelsPage = () => { - const { t } = useTranslation("channels"); - const { channels, setDialogOpen } = useDevice(); - const [activeChannel] = useState( - Types.ChannelNumber.Primary, - ); - - const currentChannel = channels.get(activeChannel); - const allChannels = Array.from(channels.values()); - - return ( - } - label={ - currentChannel - ? getChannelName(currentChannel) - : t("loading", { ns: "common" }) - } - actions={[ - { - key: "import", - icon: UploadIcon, - onClick() { - setDialogOpen("import", true); - }, - }, - { - key: "qr", - icon: QrCodeIcon, - onClick() { - setDialogOpen("QR", true); - }, - }, - ]} - > - - - {allChannels.map((channel) => ( - - {getChannelName(channel)} - - ))} - - {allChannels.map((channel) => ( - - - - ))} - - - ); -}; -export default ChannelsPage; diff --git a/packages/web/src/pages/Config/ChannelConfig.tsx b/packages/web/src/pages/Config/ChannelConfig.tsx new file mode 100644 index 00000000..7ec004fd --- /dev/null +++ b/packages/web/src/pages/Config/ChannelConfig.tsx @@ -0,0 +1,95 @@ +import { Channel } from "@app/components/PageComponents/ChannelConfig/Channel"; +import { Button } from "@components/UI/Button.tsx"; +import { Spinner } from "@components/UI/Spinner.tsx"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@components/UI/Tabs.tsx"; +import { useDevice } from "@core/stores"; +import type { Protobuf } from "@meshtastic/core"; +import i18next from "i18next"; +import { QrCodeIcon, UploadIcon } from "lucide-react"; +import { Suspense, useMemo } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +interface ConfigProps { + onFormInit: (methods: UseFormReturn) => void; +} + +export const getChannelName = (channel: Protobuf.Channel.Channel) => { + return channel.settings?.name.length + ? channel.settings?.name + : channel.index === 0 + ? i18next.t("page.broadcastLabel") + : i18next.t("page.channelIndex", { + ns: "channels", + index: channel.index, + }); +}; + +export const ChannelConfig = ({ onFormInit }: ConfigProps) => { + const { channels, getWorkingChannelConfig, setDialogOpen } = useDevice(); + const { t } = useTranslation("channels"); + + const allChannels = Array.from(channels.values()); + const flags = useMemo( + () => + new Map( + allChannels.map((channel) => [ + channel.index, + getWorkingChannelConfig(channel.index), + ]), + ), + [allChannels, getWorkingChannelConfig], + ); + + return ( + + + {allChannels.map((channel) => ( + + {getChannelName(channel)} + {flags.get(channel.index) && ( + + + + + )} + + ))} + + + + {allChannels.map((channel) => ( + + }> + + + + ))} + + ); +}; diff --git a/packages/web/src/pages/Config/DeviceConfig.tsx b/packages/web/src/pages/Config/DeviceConfig.tsx index b5db0a0f..ead1d4f5 100644 --- a/packages/web/src/pages/Config/DeviceConfig.tsx +++ b/packages/web/src/pages/Config/DeviceConfig.tsx @@ -83,7 +83,7 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => { return ( - + {tabs.map((tab) => ( { {tabs.map((tab) => ( }> - }> - - + ))} diff --git a/packages/web/src/pages/Config/ModuleConfig.tsx b/packages/web/src/pages/Config/ModuleConfig.tsx index d4119ede..c8148f07 100644 --- a/packages/web/src/pages/Config/ModuleConfig.tsx +++ b/packages/web/src/pages/Config/ModuleConfig.tsx @@ -103,7 +103,7 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => { return ( - + {tabs.map((tab) => ( { const { workingConfig, workingModuleConfig, + workingChannelConfig, connection, removeWorkingConfig, removeWorkingModuleConfig, + removeWorkingChannelConfig, setConfig, setModuleConfig, + addChannel, } = useDevice(); - const { hasErrors } = useAppStore(); + const { isDirtyForm, isValidForm, setDirtyForm, setValidForm } = + useAppStore(); const [activeConfigSection, setActiveConfigSection] = useState< - "device" | "module" + "device" | "module" | "channel" >("device"); const [isSaving, setIsSaving] = useState(false); const [formMethods, setFormMethods] = useState(null); @@ -46,16 +52,23 @@ const ConfigPage = () => { ); const handleSave = useCallback(async () => { - if (hasErrors()) { - return toast({ - title: t("toast.validationError.title"), - description: t("toast.validationError.description"), - }); - } - setIsSaving(true); try { + // Save all working channel configs first, doesn't require a commit/reboot + await Promise.all( + workingChannelConfig.map((channel) => + connection?.setChannel(channel).then(() => { + toast({ + title: t("toast.savedChannel.title", { + ns: "ui", + channelName: channel.settings?.name, + }), + }); + }), + ), + ); + await Promise.all( workingConfig.map((newConfig) => connection?.setConfig(newConfig).then(() => { @@ -84,19 +97,24 @@ const ConfigPage = () => { await connection?.commitEditSettings().then(() => { if (formMethods) { - formMethods.reset( - {}, - { - keepValues: true, - }, - ); + formMethods.reset(undefined, { + keepDirty: false, + keepErrors: false, + keepTouched: false, + keepValues: false, + }); + + // Force RHF to re-validate and emit state + formMethods.trigger(); } + workingChannelConfig.map((newChannel) => addChannel(newChannel)); workingConfig.map((newConfig) => setConfig(newConfig)); workingModuleConfig.map((newModuleConfig) => setModuleConfig(newModuleConfig), ); + removeWorkingChannelConfig(); removeWorkingConfig(); removeWorkingModuleConfig(); }); @@ -107,29 +125,48 @@ const ConfigPage = () => { }); } finally { setIsSaving(false); + setDirtyForm(false); + setValidForm(true); + toast({ + title: t("toast.saveAllSuccess.title"), + description: t("toast.saveAllSuccess.description"), + }); } }, [ - hasErrors, toast, t, workingConfig, connection, workingModuleConfig, + workingChannelConfig, formMethods, + addChannel, setConfig, setModuleConfig, removeWorkingConfig, removeWorkingModuleConfig, + removeWorkingChannelConfig, + setDirtyForm, + setValidForm, ]); const handleReset = useCallback(() => { if (formMethods) { formMethods.reset(); } - + setDirtyForm(false); + setValidForm(true); + removeWorkingChannelConfig(); removeWorkingConfig(); removeWorkingModuleConfig(); - }, [formMethods, removeWorkingConfig, removeWorkingModuleConfig]); + }, [ + formMethods, + removeWorkingConfig, + removeWorkingModuleConfig, + removeWorkingChannelConfig, + setDirtyForm, + setValidForm, + ]); const leftSidebar = useMemo( () => ( @@ -151,35 +188,35 @@ const ConfigPage = () => { isDirty={workingModuleConfig.length > 0} count={workingModuleConfig.length} /> + setActiveConfigSection("channel")} + Icon={LayersIcon} + isDirty={workingChannelConfig.length > 0} + count={workingChannelConfig.length} + /> ), - [activeConfigSection, workingConfig, workingModuleConfig, t], + [ + activeConfigSection, + workingConfig, + workingModuleConfig, + workingChannelConfig, + t, + ], ); - const buttonOpacity = useMemo(() => { - const isFormDirty = formMethods?.formState.isDirty ?? false; - const hasDirtyFields = - (Object.keys(formMethods?.formState.dirtyFields ?? {}).length ?? 0) > 0; - const hasWorkingConfig = workingConfig.length > 0; - const hasWorkingModuleConfig = workingModuleConfig.length > 0; - - const shouldShowButton = - (isFormDirty && hasDirtyFields) || - hasWorkingConfig || - hasWorkingModuleConfig; - - return shouldShowButton ? "opacity-100" : "opacity-0"; - }, [ - formMethods?.formState.isDirty, - formMethods?.formState.dirtyFields, - workingConfig, - workingModuleConfig, - ]); - - const isValid = useMemo(() => { - return Object.keys(formMethods?.formState.errors ?? {}).length === 0; - }, [formMethods?.formState.errors]); + const hasDrafts = + workingConfig.length > 0 || + workingModuleConfig.length > 0 || + workingChannelConfig.length > 0; + const isValid = isValidForm(); + const isDirty = isDirtyForm(); + const hasPending = hasDrafts || isDirty; + const buttonOpacity = hasPending ? "opacity-100" : "opacity-0"; + const saveDisabled = isSaving || !isValid || !hasPending; const actions = useMemo( () => [ @@ -209,10 +246,7 @@ const ConfigPage = () => { key: "save", icon: !isValid ? SaveOff : SaveIcon, isLoading: isSaving, - disabled: - isSaving || - !isValid || - (workingConfig.length === 0 && workingModuleConfig.length === 0), + disabled: saveDisabled, iconClasses: !isValid ? "text-red-400 cursor-not-allowed" : "cursor-pointer", @@ -228,9 +262,8 @@ const ConfigPage = () => { [ isSaving, isValid, + saveDisabled, buttonOpacity, - workingConfig, - workingModuleConfig, handleReset, handleSave, t, @@ -244,14 +277,18 @@ const ConfigPage = () => { label={ activeConfigSection === "device" ? t("navigation.radioConfig") - : t("navigation.moduleConfig") + : activeConfigSection === "module" + ? t("navigation.moduleConfig") + : t("navigation.channelConfig") } actions={actions} > {activeConfigSection === "device" ? ( - ) : ( + ) : activeConfigSection === "module" ? ( + ) : ( + )} ); diff --git a/packages/web/src/pages/Messages.tsx b/packages/web/src/pages/Messages.tsx index af0c0a37..bcbac8c4 100644 --- a/packages/web/src/pages/Messages.tsx +++ b/packages/web/src/pages/Messages.tsx @@ -18,7 +18,7 @@ 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/Channels.tsx"; +import { getChannelName } from "@pages/Config/ChannelConfig.tsx"; import { useNavigate, useParams } from "@tanstack/react-router"; import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; import { diff --git a/packages/web/src/routes.tsx b/packages/web/src/routes.tsx index 8fc62cbf..e134c4b7 100644 --- a/packages/web/src/routes.tsx +++ b/packages/web/src/routes.tsx @@ -1,6 +1,5 @@ import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import type { useAppStore, useMessageStore } from "@core/stores"; -import ChannelsPage from "@pages/Channels.tsx"; import ConfigPage from "@pages/Config/index.tsx"; import { Dashboard } from "@pages/Dashboard/index.tsx"; import MapPage from "@pages/Map/index.tsx"; @@ -81,12 +80,6 @@ const configRoute = createRoute({ component: ConfigPage, }); -const channelsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/channels", - component: ChannelsPage, -}); - const nodesRoute = createRoute({ getParentRoute: () => rootRoute, path: "/nodes", @@ -105,7 +98,6 @@ const routeTree = rootRoute.addChildren([ messagesWithParamsRoute, mapRoute, configRoute, - channelsRoute, nodesRoute, dialogWithParamsRoute, ]); diff --git a/packages/web/src/validation/channel.ts b/packages/web/src/validation/channel.ts index dea4583e..904cec13 100644 --- a/packages/web/src/validation/channel.ts +++ b/packages/web/src/validation/channel.ts @@ -35,3 +35,6 @@ export function makeChannelSchema(allowedBytes: number) { role: RoleEnum, }); } + +const ChannelValidationSchema = makeChannelSchema(0); // generate a schema that doesn't validate PSK length, just structure, for type purposes +export type ChannelValidation = z.infer; From 734316f994e6cc800f416106f99ab44cf1c4c240 Mon Sep 17 00:00:00 2001 From: philon- Date: Sun, 24 Aug 2025 21:48:35 +0200 Subject: [PATCH 2/5] Improve import dialog config comparison and UI labels --- .../src/components/Dialog/ImportDialog.tsx | 61 ++++++++++++------- packages/web/src/pages/Config/index.tsx | 10 +-- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/packages/web/src/components/Dialog/ImportDialog.tsx b/packages/web/src/components/Dialog/ImportDialog.tsx index 339dd733..12889bbc 100644 --- a/packages/web/src/components/Dialog/ImportDialog.tsx +++ b/packages/web/src/components/Dialog/ImportDialog.tsx @@ -20,6 +20,7 @@ import { } from "@components/UI/Select.tsx"; import { Switch } from "@components/UI/Switch.tsx"; import { useDevice } from "@core/stores"; +import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; import { Protobuf } from "@meshtastic/core"; import { toByteArray } from "base64-js"; import { useEffect, useState } from "react"; @@ -32,7 +33,7 @@ export interface ImportDialogProps { } export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { - const { config } = useDevice(); + const { config, channels } = useDevice(); const { t } = useTranslation("dialog"); const [importDialogInput, setImportDialogInput] = useState(""); const [channelSet, setChannelSet] = useState(); @@ -89,31 +90,45 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { return; } - setWorkingChannelConfig( - create(Protobuf.Channel.ChannelSchema, { - index: importIndex[index], - role: - importIndex[index] === 0 - ? Protobuf.Channel.Channel_Role.PRIMARY - : Protobuf.Channel.Channel_Role.SECONDARY, - settings: ch, - }), - ); + const payload = create(Protobuf.Channel.ChannelSchema, { + index: importIndex[index], + role: + importIndex[index] === 0 + ? Protobuf.Channel.Channel_Role.PRIMARY + : Protobuf.Channel.Channel_Role.SECONDARY, + settings: ch, + }); + + if ( + deepCompareConfig( + channels.get(importIndex[index] ?? 0), + payload, + true, + ) + ) { + return; + } + + setWorkingChannelConfig(payload); }, ); if (channelSet?.loraConfig && updateConfig) { - setWorkingConfig( - create(Protobuf.Config.ConfigSchema, { - payloadVariant: { - case: "lora", - value: { - ...config.lora, - ...channelSet.loraConfig, - }, + const payload = create(Protobuf.Config.ConfigSchema, { + payloadVariant: { + case: "lora", + value: { + ...config.lora, + ...channelSet.loraConfig, }, - }), - ); + }, + }); + + if (deepCompareConfig(config.lora, payload, true)) { + return; + } + + setWorkingConfig(payload); } // Reset state after import setImportDialogInput(""); @@ -180,7 +195,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { {channelSet?.settings.map((channel, index) => (