New channel config (#807)
* Channel config rework Add staged channel config with tabbed UI, import/export workflow, and global form state refactor * Improve import dialog config comparison and UI labels * Review fixes * Improve state handling * Fix default filter behaviour --------- Co-authored-by: philon- <philon-@users.noreply.github.com>
This commit is contained in:
co-authored by
philon-
parent
01fa030ef9
commit
1946000d14
@@ -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."
|
||||
|
||||
@@ -16,15 +16,19 @@
|
||||
}
|
||||
},
|
||||
"import": {
|
||||
"description": "The current LoRa configuration will be overridden.",
|
||||
"description": "Import a Channel Set from a Meshtastic URL. <br />Valid Meshtasic URLs start with \"<italic>https://meshtastic.org/e/...</italic>\"",
|
||||
"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?",
|
||||
"title": "Import Channel Set"
|
||||
"useLoraConfig": "Import LoRa Config",
|
||||
"presetDescription": "The current LoRa Config will be replaced.",
|
||||
"title": "Import Channels"
|
||||
},
|
||||
"locationResponse": {
|
||||
"title": "Location: {{identifier}}",
|
||||
|
||||
@@ -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": "Saved",
|
||||
"description": "All configuration changes have been saved."
|
||||
},
|
||||
"favoriteNode": {
|
||||
"title": "{{action}} {{nodeName}} {{direction}} favorites.",
|
||||
"action": {
|
||||
|
||||
@@ -11,13 +11,20 @@ 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 { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
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";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
export interface ImportDialogProps {
|
||||
open: boolean;
|
||||
@@ -26,12 +33,15 @@ export interface ImportDialogProps {
|
||||
}
|
||||
|
||||
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
const { config, channels } = useDevice();
|
||||
const { t } = useTranslation("dialog");
|
||||
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
||||
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
||||
const [validUrl, setValidUrl] = useState<boolean>(false);
|
||||
const [updateConfig, setUpdateConfig] = useState<boolean>(true);
|
||||
const [importIndex, setImportIndex] = useState<number[]>([]);
|
||||
|
||||
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 +65,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,29 +86,62 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
const apply = () => {
|
||||
channelSet?.settings.map(
|
||||
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
|
||||
connection?.setChannel(
|
||||
create(Protobuf.Channel.ChannelSchema, {
|
||||
index,
|
||||
role:
|
||||
index === 0
|
||||
? Protobuf.Channel.Channel_Role.PRIMARY
|
||||
: Protobuf.Channel.Channel_Role.SECONDARY,
|
||||
settings: ch,
|
||||
}),
|
||||
);
|
||||
if (importIndex[index] === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
) {
|
||||
setWorkingChannelConfig(payload);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (channelSet?.loraConfig) {
|
||||
connection?.setConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "lora",
|
||||
value: channelSet.loraConfig,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (channelSet?.loraConfig && updateConfig) {
|
||||
const payload = {
|
||||
...config.lora,
|
||||
...channelSet.loraConfig,
|
||||
};
|
||||
|
||||
if (!deepCompareConfig(config.lora, payload, true)) {
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "lora",
|
||||
value: payload,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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 (
|
||||
@@ -102,55 +150,85 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("import.title")}</DialogTitle>
|
||||
<DialogDescription>{t("import.description")}</DialogDescription>
|
||||
<DialogDescription>
|
||||
<Trans
|
||||
i18nKey={"import.description"}
|
||||
components={{ italic: <i />, br: <br /> }}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>{t("import.channelSetUrl")}</Label>
|
||||
<Input
|
||||
value={importDialogInput}
|
||||
suffix={validUrl ? "✅" : "❌"}
|
||||
variant={
|
||||
importDialogInput === ""
|
||||
? "default"
|
||||
: validUrl
|
||||
? "dirty"
|
||||
: "invalid"
|
||||
}
|
||||
onChange={(e) => {
|
||||
setImportDialogInput(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{validUrl && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-6 mt-2">
|
||||
<div className="flex w-full gap-2">
|
||||
<div className="w-36">
|
||||
<Label>{t("import.usePreset")}</Label>
|
||||
<div className=" flex items-center">
|
||||
<Switch
|
||||
disabled
|
||||
checked={channelSet?.loraConfig?.usePreset ?? true}
|
||||
className="ml-3 mr-4"
|
||||
checked={updateConfig}
|
||||
onCheckedChange={(next) => setUpdateConfig(next)}
|
||||
/>
|
||||
<Label className="">
|
||||
{t("import.useLoraConfig")}
|
||||
<span className="block pt-2 font-normal text-s">
|
||||
{t("import.presetDescription")}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
{/* <Select
|
||||
label="Modem Preset"
|
||||
disabled
|
||||
value={channelSet?.loraConfig?.modemPreset}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
|
||||
</Select> */}
|
||||
</div>
|
||||
{/* <Select
|
||||
label="Region"
|
||||
disabled
|
||||
value={channelSet?.loraConfig?.region}
|
||||
>
|
||||
{renderOptions(Protobuf.Config_LoRaConfig_RegionCode)}
|
||||
</Select> */}
|
||||
|
||||
<span className="text-md block font-medium text-text-primary">
|
||||
{t("import.channels")}
|
||||
</span>
|
||||
<div className="flex w-40 flex-col gap-1">
|
||||
{channelSet?.settings.map((channel) => (
|
||||
<div className="flex justify-between" key={channel.id}>
|
||||
<Label>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex items-center font-semibold text-sm">
|
||||
<span className="flex-1">{t("import.channelName")}</span>
|
||||
<span className="flex-1">{t("import.channelSlot")}</span>
|
||||
</div>
|
||||
{channelSet?.settings.map((channel, index) => (
|
||||
<div
|
||||
className="flex items-center"
|
||||
key={`channel_${channel.id}_${index}`}
|
||||
>
|
||||
<Label className="flex-1">
|
||||
{channel.name.length
|
||||
? channel.name
|
||||
: `${t("import.channelPrefix")}${channel.id}`}
|
||||
</Label>
|
||||
<Checkbox key={channel.id} />
|
||||
<Select
|
||||
onValueChange={(value) => onSelectChange(value, index)}
|
||||
value={importIndex[index]?.toString()}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 8 }, (_, i) => i).map((i) => (
|
||||
<SelectItem
|
||||
key={`index_${i}`}
|
||||
disabled={importIndex.includes(i) && index !== i}
|
||||
value={i.toString()}
|
||||
>
|
||||
{i === 0
|
||||
? t("import.primary")
|
||||
: `${t("import.channelPrefix")}${i}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="-1">
|
||||
{t("import.doNotImport")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ export const QRDialog = ({
|
||||
: `${t("page.channelIndex", {
|
||||
ns: "channels",
|
||||
index: channel.index,
|
||||
})}${channel.index}`}
|
||||
})}`}
|
||||
</Label>
|
||||
<Checkbox
|
||||
key={channel.index}
|
||||
|
||||
@@ -7,8 +7,6 @@ import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||
import { useAppStore } from "@core/stores";
|
||||
import { dotPaths } from "@core/utils/dotPath.ts";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
type Control,
|
||||
@@ -66,7 +64,6 @@ export interface DynamicFormProps<T extends FieldValues> {
|
||||
fields: FieldProps<T>[];
|
||||
}[];
|
||||
validationSchema?: ZodType<T>;
|
||||
formId?: string;
|
||||
}
|
||||
|
||||
export type DynamicFormFormInit<T extends FieldValues> = (
|
||||
@@ -83,10 +80,8 @@ export function DynamicForm<T extends FieldValues>({
|
||||
values,
|
||||
fieldGroups,
|
||||
validationSchema,
|
||||
formId,
|
||||
}: DynamicFormProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const { addError, removeError } = useAppStore();
|
||||
|
||||
const internalMethods = useForm<T>({
|
||||
mode: "onChange",
|
||||
@@ -95,6 +90,7 @@ export function DynamicForm<T extends FieldValues>({
|
||||
? createZodResolver(validationSchema)
|
||||
: undefined,
|
||||
shouldFocusError: false,
|
||||
shouldUnregister: true,
|
||||
resetOptions: { keepDefaultValues: true },
|
||||
values,
|
||||
});
|
||||
@@ -110,23 +106,6 @@ export function DynamicForm<T extends FieldValues>({
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
const isDisabled = (
|
||||
disabledBy?: DisabledBy<T>[],
|
||||
disabled?: boolean,
|
||||
|
||||
@@ -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<T> extends BaseFormBuilderProps<T> {
|
||||
@@ -34,36 +33,29 @@ export function PasswordGenerator<T extends FieldValues>({
|
||||
invalid,
|
||||
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
||||
const { isVisible } = usePasswordVisibilityToggle();
|
||||
const { trigger } = useFormContext();
|
||||
|
||||
useEffect(() => {
|
||||
trigger(field.name);
|
||||
}, [field.name, trigger]);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...rest } }) => (
|
||||
render={({ field: controllerField }) => (
|
||||
<Generator
|
||||
type={field.hide && !isVisible ? "password" : "text"}
|
||||
id={field.id}
|
||||
devicePSKBitCount={field.devicePSKBitCount}
|
||||
bits={field.bits}
|
||||
inputChange={(e) => {
|
||||
if (field.inputChange) {
|
||||
field.inputChange(e);
|
||||
}
|
||||
onChange(e);
|
||||
const value = e.target.value;
|
||||
field.inputChange?.(e); // call any external handler
|
||||
controllerField.onChange(value); // ensure RHF receives just the value
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -70,6 +70,9 @@ export function SelectInput<T extends FieldValues>({
|
||||
|
||||
const handleValueChange = async (newValue: string) => {
|
||||
const selectedKey = valueToKeyMap[newValue];
|
||||
if (!selectedKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.validate) {
|
||||
const isValid = await field.validate(selectedKey);
|
||||
|
||||
+113
-68
@@ -1,118 +1,165 @@
|
||||
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, useRef, 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<ChannelValidation>;
|
||||
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<boolean>(false);
|
||||
const [pass, setPass] = useState<string>(
|
||||
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
||||
);
|
||||
const [byteCount, setBytes] = useState<number>(
|
||||
channel?.settings?.psk.length ?? 16,
|
||||
effectiveConfig?.settings?.psk.length ?? 16,
|
||||
);
|
||||
|
||||
const ChannelValidationSchema = useMemo(() => {
|
||||
return makeChannelSchema(byteCount);
|
||||
}, [byteCount]);
|
||||
|
||||
type ChannelValidation = zodInfer<typeof ChannelValidationSchema>;
|
||||
const formMethods = useForm<ChannelValidation>({
|
||||
mode: "onChange",
|
||||
defaultValues: defaultValues as DefaultValues<ChannelValidation>,
|
||||
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;
|
||||
const lastEffectiveRef = useRef<number>(effectiveByteCount);
|
||||
useEffect(() => {
|
||||
if (effectiveByteCount !== lastEffectiveRef.current) {
|
||||
lastEffectiveRef.current = effectiveByteCount;
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<DynamicForm<ChannelValidation>
|
||||
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 +187,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,
|
||||
},
|
||||
@@ -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={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -62,7 +62,6 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={NetworkValidationSchema}
|
||||
formId="Config_NetworkConfig"
|
||||
defaultValues={{
|
||||
...config.network,
|
||||
ipv4Config: {
|
||||
|
||||
@@ -62,7 +62,6 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
|
||||
}}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={PositionValidationSchema}
|
||||
formId="Config_PositionConfig"
|
||||
defaultValues={config.position}
|
||||
values={getEffectiveConfig("position")}
|
||||
fieldGroups={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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) => {
|
||||
<DynamicForm<RawSecurity>
|
||||
propMethods={formMethods}
|
||||
onSubmit={onSubmit}
|
||||
formId="Config_SecurityConfig"
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("security.title"),
|
||||
|
||||
@@ -50,7 +50,6 @@ export const AmbientLighting = ({
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={AmbientLightingValidationSchema}
|
||||
formId="ModuleConfig_AmbientLightingConfig"
|
||||
defaultValues={moduleConfig.ambientLighting}
|
||||
values={getEffectiveModuleConfig("ambientLighting")}
|
||||
fieldGroups={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -51,7 +51,6 @@ export const CannedMessage = ({
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={CannedMessageValidationSchema}
|
||||
formId="ModuleConfig_CannedMessageConfig"
|
||||
defaultValues={moduleConfig.cannedMessage}
|
||||
values={getEffectiveModuleConfig("cannedMessage")}
|
||||
fieldGroups={[
|
||||
|
||||
@@ -51,7 +51,6 @@ export const DetectionSensor = ({
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={DetectionSensorValidationSchema}
|
||||
formId="ModuleConfig_DetectionSensorConfig"
|
||||
defaultValues={moduleConfig.detectionSensor}
|
||||
values={getEffectiveModuleConfig("detectionSensor")}
|
||||
fieldGroups={[
|
||||
|
||||
@@ -51,7 +51,6 @@ export const ExternalNotification = ({
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={ExternalNotificationValidationSchema}
|
||||
formId="ModuleConfig_ExternalNotificationConfig"
|
||||
defaultValues={moduleConfig.externalNotification}
|
||||
values={getEffectiveModuleConfig("externalNotification")}
|
||||
fieldGroups={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -13,7 +13,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,
|
||||
|
||||
@@ -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<HTMLInputElement>(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 (
|
||||
<>
|
||||
<Input
|
||||
@@ -102,7 +83,6 @@ const Generator = ({
|
||||
value={value}
|
||||
onChange={inputChange}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
showCopyButton={showCopyButton}
|
||||
showPasswordToggle={showPasswordToggle}
|
||||
/>
|
||||
|
||||
@@ -8,11 +8,6 @@ export interface RasterSource {
|
||||
tileSize: number;
|
||||
}
|
||||
|
||||
interface ErrorState {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
selectedDeviceId: number;
|
||||
devices: {
|
||||
@@ -24,7 +19,6 @@ interface AppState {
|
||||
nodeNumToBeRemoved: number;
|
||||
connectDialogOpen: boolean;
|
||||
nodeNumDetails: number;
|
||||
errors: ErrorState[];
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => void;
|
||||
addRasterSource: (source: RasterSource) => void;
|
||||
@@ -36,15 +30,6 @@ interface AppState {
|
||||
setNodeNumToBeRemoved: (nodeNum: number) => void;
|
||||
setConnectDialogOpen: (open: boolean) => void;
|
||||
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;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()((set, get) => ({
|
||||
@@ -56,7 +41,6 @@ export const useAppStore = create<AppState>()((set, get) => ({
|
||||
connectDialogOpen: false,
|
||||
nodeNumToBeRemoved: 0,
|
||||
nodeNumDetails: 0,
|
||||
errors: [],
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => {
|
||||
set(
|
||||
@@ -114,47 +98,4 @@ export const useAppStore = create<AppState>()((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) => {
|
||||
const state = get();
|
||||
return state.errors.some((err) => err.field === field);
|
||||
},
|
||||
addError: (field: string, message: string) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = [
|
||||
...draft.errors.filter((e) => e.field !== field),
|
||||
{ field, message },
|
||||
];
|
||||
}),
|
||||
);
|
||||
},
|
||||
removeError: (field: string) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = draft.errors.filter((e) => e.field !== field);
|
||||
}),
|
||||
);
|
||||
},
|
||||
clearErrors: () => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = [];
|
||||
}),
|
||||
);
|
||||
},
|
||||
setNewErrors: (newErrors: ErrorState[]) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = newErrors;
|
||||
}),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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(),
|
||||
@@ -57,6 +58,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(),
|
||||
|
||||
@@ -30,6 +30,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<number, Protobuf.Mesh.DeviceMetadata>;
|
||||
traceroutes: Map<
|
||||
@@ -85,6 +86,11 @@ export interface Device {
|
||||
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
|
||||
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;
|
||||
@@ -139,6 +145,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema),
|
||||
workingConfig: [],
|
||||
workingModuleConfig: [],
|
||||
workingChannelConfig: [],
|
||||
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
|
||||
metadata: new Map(),
|
||||
traceroutes: new Map(),
|
||||
@@ -443,6 +450,61 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
};
|
||||
},
|
||||
|
||||
setWorkingChannelConfig: (config: Protobuf.Channel.Channel) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((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<PrivateDeviceState>((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<PrivateDeviceState>((draft) => {
|
||||
|
||||
@@ -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>(
|
||||
Types.ChannelNumber.Primary,
|
||||
);
|
||||
|
||||
const currentChannel = channels.get(activeChannel);
|
||||
const allChannels = Array.from(channels.values());
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
contentClassName="overflow-auto"
|
||||
leftBar={<Sidebar />}
|
||||
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);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Tabs defaultValue="0">
|
||||
<TabsList className="dark:bg-slate-800">
|
||||
{allChannels.map((channel) => (
|
||||
<TabsTrigger
|
||||
key={channel.index}
|
||||
value={channel.index.toString()}
|
||||
className="dark:text-white"
|
||||
>
|
||||
{getChannelName(channel)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{allChannels.map((channel) => (
|
||||
<TabsContent key={channel.index} value={channel.index.toString()}>
|
||||
<Channel key={channel.index} channel={channel} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
export default ChannelsPage;
|
||||
@@ -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: <T extends object>(methods: UseFormReturn<T>) => 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 (
|
||||
<Tabs defaultValue="channel_0">
|
||||
<TabsList className="w-full dark:bg-slate-700">
|
||||
{allChannels.map((channel) => (
|
||||
<TabsTrigger
|
||||
key={`channel_${channel.index}`}
|
||||
value={`channel_${channel.index}`}
|
||||
className="dark:text-white relative"
|
||||
>
|
||||
{getChannelName(channel)}
|
||||
{flags.get(channel.index) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
|
||||
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<Button
|
||||
className="ml-auto mr-1 h-8"
|
||||
onClick={() => setDialogOpen("import", true)}
|
||||
>
|
||||
<UploadIcon className="mr-2" size={14} />
|
||||
{t("page.import")}
|
||||
</Button>
|
||||
<Button className=" h-8" onClick={() => setDialogOpen("QR", true)}>
|
||||
<QrCodeIcon className="mr-2" size={14} />
|
||||
{t("page.export")}
|
||||
</Button>
|
||||
</TabsList>
|
||||
{allChannels.map((channel) => (
|
||||
<TabsContent
|
||||
key={`channel_${channel.index}`}
|
||||
value={`channel_${channel.index}`}
|
||||
>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<Channel
|
||||
key={channel.index}
|
||||
onFormInit={onFormInit}
|
||||
channel={channel}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -83,7 +83,7 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={t("page.tabDevice")}>
|
||||
<TabsList className="dark:bg-slate-700">
|
||||
<TabsList className="w-full dark:bg-slate-700">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.label}
|
||||
@@ -103,9 +103,7 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.label} value={tab.label}>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</Suspense>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
@@ -103,7 +103,7 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={t("page.tabMqtt")}>
|
||||
<TabsList className="dark:bg-slate-800">
|
||||
<TabsList className="w-full dark:bg-slate-800">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.label}
|
||||
|
||||
@@ -3,18 +3,20 @@ import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { useAppStore, useDevice } from "@core/stores";
|
||||
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 {
|
||||
BoxesIcon,
|
||||
LayersIcon,
|
||||
RefreshCwIcon,
|
||||
SaveIcon,
|
||||
SaveOff,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FieldValues, UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -22,18 +24,22 @@ const ConfigPage = () => {
|
||||
const {
|
||||
workingConfig,
|
||||
workingModuleConfig,
|
||||
workingChannelConfig,
|
||||
connection,
|
||||
removeWorkingConfig,
|
||||
removeWorkingModuleConfig,
|
||||
removeWorkingChannelConfig,
|
||||
setConfig,
|
||||
setModuleConfig,
|
||||
addChannel,
|
||||
} = useDevice();
|
||||
const { hasErrors } = useAppStore();
|
||||
|
||||
const [activeConfigSection, setActiveConfigSection] = useState<
|
||||
"device" | "module"
|
||||
"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<UseFormReturn | null>(null);
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
@@ -41,21 +47,51 @@ const ConfigPage = () => {
|
||||
const onFormInit = useCallback(
|
||||
<T extends FieldValues>(methods: UseFormReturn<T>) => {
|
||||
setFormMethods(methods as UseFormReturn);
|
||||
|
||||
setRhfState({
|
||||
// Assume defailt on init, changes will be caught by subscription
|
||||
isDirty: false,
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
// Unsubscribe from previous subscriptions & subscribe to form changes
|
||||
unsubRef.current?.();
|
||||
unsubRef.current = methods.subscribe({
|
||||
formState: { isDirty: true, isValid: true },
|
||||
callback: ({ isValid, isDirty }) => {
|
||||
setRhfState({
|
||||
isDirty: isDirty ?? false,
|
||||
isValid: isValid ?? true,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (hasErrors()) {
|
||||
return toast({
|
||||
title: t("toast.validationError.title"),
|
||||
description: t("toast.validationError.description"),
|
||||
});
|
||||
}
|
||||
// Cleanup subscription on unmount
|
||||
useEffect(() => {
|
||||
return () => unsubRef.current?.();
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
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(() => {
|
||||
@@ -82,24 +118,31 @@ const ConfigPage = () => {
|
||||
),
|
||||
);
|
||||
|
||||
await connection?.commitEditSettings().then(() => {
|
||||
if (formMethods) {
|
||||
formMethods.reset(
|
||||
{},
|
||||
{
|
||||
keepValues: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (workingConfig.length > 0 || workingModuleConfig.length > 0) {
|
||||
await connection?.commitEditSettings();
|
||||
}
|
||||
|
||||
workingConfig.map((newConfig) => setConfig(newConfig));
|
||||
workingModuleConfig.map((newModuleConfig) =>
|
||||
setModuleConfig(newModuleConfig),
|
||||
);
|
||||
workingChannelConfig.forEach((newChannel) => addChannel(newChannel));
|
||||
workingConfig.forEach((newConfig) => setConfig(newConfig));
|
||||
workingModuleConfig.forEach((newModuleConfig) =>
|
||||
setModuleConfig(newModuleConfig),
|
||||
);
|
||||
|
||||
removeWorkingConfig();
|
||||
removeWorkingModuleConfig();
|
||||
});
|
||||
removeWorkingChannelConfig();
|
||||
removeWorkingConfig();
|
||||
removeWorkingModuleConfig();
|
||||
|
||||
if (formMethods) {
|
||||
formMethods.reset(formMethods.getValues(), {
|
||||
keepDirty: false,
|
||||
keepErrors: false,
|
||||
keepTouched: false,
|
||||
keepValues: true,
|
||||
});
|
||||
|
||||
// Force RHF to re-validate and emit state
|
||||
formMethods.trigger();
|
||||
}
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: t("toast.configSaveError.title"),
|
||||
@@ -107,29 +150,40 @@ const ConfigPage = () => {
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
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,
|
||||
]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (formMethods) {
|
||||
formMethods.reset();
|
||||
}
|
||||
|
||||
removeWorkingChannelConfig();
|
||||
removeWorkingConfig();
|
||||
removeWorkingModuleConfig();
|
||||
}, [formMethods, removeWorkingConfig, removeWorkingModuleConfig]);
|
||||
}, [
|
||||
formMethods,
|
||||
removeWorkingConfig,
|
||||
removeWorkingModuleConfig,
|
||||
removeWorkingChannelConfig,
|
||||
]);
|
||||
|
||||
const leftSidebar = useMemo(
|
||||
() => (
|
||||
@@ -151,35 +205,33 @@ const ConfigPage = () => {
|
||||
isDirty={workingModuleConfig.length > 0}
|
||||
count={workingModuleConfig.length}
|
||||
/>
|
||||
<SidebarButton
|
||||
label={t("navigation.channelConfig")}
|
||||
active={activeConfigSection === "channel"}
|
||||
onClick={() => setActiveConfigSection("channel")}
|
||||
Icon={LayersIcon}
|
||||
isDirty={workingChannelConfig.length > 0}
|
||||
count={workingChannelConfig.length}
|
||||
/>
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
),
|
||||
[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 hasPending = hasDrafts || rhfState.isDirty;
|
||||
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
|
||||
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
@@ -207,15 +259,13 @@ const ConfigPage = () => {
|
||||
},
|
||||
{
|
||||
key: "save",
|
||||
icon: !isValid ? SaveOff : SaveIcon,
|
||||
icon: !hasPending ? SaveOff : SaveIcon,
|
||||
isLoading: isSaving,
|
||||
disabled:
|
||||
isSaving ||
|
||||
!isValid ||
|
||||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
|
||||
iconClasses: !isValid
|
||||
? "text-red-400 cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
disabled: saveDisabled,
|
||||
iconClasses:
|
||||
!rhfState.isValid && hasPending
|
||||
? "text-red-400 cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
className: cn([
|
||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"hover:dark:bg-slate-300 hover:dark:text-black",
|
||||
@@ -227,10 +277,10 @@ const ConfigPage = () => {
|
||||
],
|
||||
[
|
||||
isSaving,
|
||||
isValid,
|
||||
hasPending,
|
||||
rhfState.isValid,
|
||||
saveDisabled,
|
||||
buttonOpacity,
|
||||
workingConfig,
|
||||
workingModuleConfig,
|
||||
handleReset,
|
||||
handleSave,
|
||||
t,
|
||||
@@ -244,14 +294,18 @@ const ConfigPage = () => {
|
||||
label={
|
||||
activeConfigSection === "device"
|
||||
? t("navigation.radioConfig")
|
||||
: t("navigation.moduleConfig")
|
||||
: activeConfigSection === "module"
|
||||
? t("navigation.moduleConfig")
|
||||
: t("navigation.channelConfig")
|
||||
}
|
||||
actions={actions}
|
||||
>
|
||||
{activeConfigSection === "device" ? (
|
||||
<DeviceConfig onFormInit={onFormInit} />
|
||||
) : (
|
||||
) : activeConfigSection === "module" ? (
|
||||
<ModuleConfig onFormInit={onFormInit} />
|
||||
) : (
|
||||
<ChannelConfig onFormInit={onFormInit} />
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,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 {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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<typeof ChannelValidationSchema>;
|
||||
|
||||
Reference in New Issue
Block a user