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",
|
"sectionLabel": "Channels",
|
||||||
"channelName": "Channel: {{channelName}}",
|
"channelName": "Channel: {{channelName}}",
|
||||||
"broadcastLabel": "Primary",
|
"broadcastLabel": "Primary",
|
||||||
"channelIndex": "Ch {{index}}"
|
"channelIndex": "Ch {{index}}",
|
||||||
|
"import": "Import",
|
||||||
|
"export": "Export"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"pskInvalid": "Please enter a valid {{bits}} bit PSK."
|
"pskInvalid": "Please enter a valid {{bits}} bit PSK."
|
||||||
|
|||||||
@@ -16,15 +16,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"import": {
|
"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": {
|
"error": {
|
||||||
"invalidUrl": "Invalid Meshtastic URL"
|
"invalidUrl": "Invalid Meshtastic URL"
|
||||||
},
|
},
|
||||||
"channelPrefix": "Channel: ",
|
"channelPrefix": "Channel ",
|
||||||
|
"primary": "Primary ",
|
||||||
|
"doNotImport": "No not import",
|
||||||
|
"channelName": "Name",
|
||||||
|
"channelSlot": "Slot",
|
||||||
"channelSetUrl": "Channel Set/QR Code URL",
|
"channelSetUrl": "Channel Set/QR Code URL",
|
||||||
"channels": "Channels:",
|
"useLoraConfig": "Import LoRa Config",
|
||||||
"usePreset": "Use Preset?",
|
"presetDescription": "The current LoRa Config will be replaced.",
|
||||||
"title": "Import Channel Set"
|
"title": "Import Channels"
|
||||||
},
|
},
|
||||||
"locationResponse": {
|
"locationResponse": {
|
||||||
"title": "Location: {{identifier}}",
|
"title": "Location: {{identifier}}",
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
"messages": "Messages",
|
"messages": "Messages",
|
||||||
"map": "Map",
|
"map": "Map",
|
||||||
"config": "Config",
|
"config": "Config",
|
||||||
|
"channels": "Channels",
|
||||||
"radioConfig": "Radio Config",
|
"radioConfig": "Radio Config",
|
||||||
"moduleConfig": "Module Config",
|
"moduleConfig": "Module Config",
|
||||||
"channels": "Channels",
|
"channelConfig": "Channel Config",
|
||||||
"nodes": "Nodes"
|
"nodes": "Nodes"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -67,6 +68,10 @@
|
|||||||
"title": "Saving Config",
|
"title": "Saving Config",
|
||||||
"description": "The configuration change {{case}} has been saved."
|
"description": "The configuration change {{case}} has been saved."
|
||||||
},
|
},
|
||||||
|
"saveAllSuccess": {
|
||||||
|
"title": "Saved",
|
||||||
|
"description": "All configuration changes have been saved."
|
||||||
|
},
|
||||||
"favoriteNode": {
|
"favoriteNode": {
|
||||||
"title": "{{action}} {{nodeName}} {{direction}} favorites.",
|
"title": "{{action}} {{nodeName}} {{direction}} favorites.",
|
||||||
"action": {
|
"action": {
|
||||||
|
|||||||
@@ -11,13 +11,20 @@ import {
|
|||||||
} from "@components/UI/Dialog.tsx";
|
} from "@components/UI/Dialog.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
import { Label } from "@components/UI/Label.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 { Switch } from "@components/UI/Switch.tsx";
|
||||||
import { useDevice } from "@core/stores";
|
import { useDevice } from "@core/stores";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { toByteArray } from "base64-js";
|
import { toByteArray } from "base64-js";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { Checkbox } from "../UI/Checkbox/index.tsx";
|
|
||||||
|
|
||||||
export interface ImportDialogProps {
|
export interface ImportDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -26,12 +33,15 @@ export interface ImportDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||||
|
const { config, channels } = useDevice();
|
||||||
const { t } = useTranslation("dialog");
|
const { t } = useTranslation("dialog");
|
||||||
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
||||||
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
||||||
const [validUrl, setValidUrl] = useState<boolean>(false);
|
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(() => {
|
useEffect(() => {
|
||||||
// the channel information is contained in the URL's fragment, which will be present after a
|
// 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, "+")
|
||||||
.replace(/_/g, "/");
|
.replace(/_/g, "/");
|
||||||
setChannelSet(
|
|
||||||
fromBinary(
|
const newChannelSet = fromBinary(
|
||||||
Protobuf.AppOnly.ChannelSetSchema,
|
Protobuf.AppOnly.ChannelSetSchema,
|
||||||
toByteArray(paddedString),
|
toByteArray(paddedString),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const newImportChannelArray = newChannelSet.settings.map((_, idx) => idx);
|
||||||
|
|
||||||
|
setChannelSet(newChannelSet);
|
||||||
|
setImportIndex(newImportChannelArray);
|
||||||
|
setUpdateConfig(newChannelSet?.loraConfig !== undefined);
|
||||||
setValidUrl(true);
|
setValidUrl(true);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
setValidUrl(false);
|
setValidUrl(false);
|
||||||
@@ -71,29 +86,62 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
|||||||
const apply = () => {
|
const apply = () => {
|
||||||
channelSet?.settings.map(
|
channelSet?.settings.map(
|
||||||
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
|
(ch: Protobuf.Channel.ChannelSettings, index: number) => {
|
||||||
connection?.setChannel(
|
if (importIndex[index] === -1) {
|
||||||
create(Protobuf.Channel.ChannelSchema, {
|
return;
|
||||||
index,
|
}
|
||||||
role:
|
|
||||||
index === 0
|
const payload = create(Protobuf.Channel.ChannelSchema, {
|
||||||
? Protobuf.Channel.Channel_Role.PRIMARY
|
index: importIndex[index],
|
||||||
: Protobuf.Channel.Channel_Role.SECONDARY,
|
role:
|
||||||
settings: ch,
|
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) {
|
if (channelSet?.loraConfig && updateConfig) {
|
||||||
connection?.setConfig(
|
const payload = {
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
...config.lora,
|
||||||
payloadVariant: {
|
...channelSet.loraConfig,
|
||||||
case: "lora",
|
};
|
||||||
value: 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 (
|
return (
|
||||||
@@ -102,55 +150,85 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
|||||||
<DialogClose />
|
<DialogClose />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("import.title")}</DialogTitle>
|
<DialogTitle>{t("import.title")}</DialogTitle>
|
||||||
<DialogDescription>{t("import.description")}</DialogDescription>
|
<DialogDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey={"import.description"}
|
||||||
|
components={{ italic: <i />, br: <br /> }}
|
||||||
|
/>
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Label>{t("import.channelSetUrl")}</Label>
|
<Label>{t("import.channelSetUrl")}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={importDialogInput}
|
value={importDialogInput}
|
||||||
suffix={validUrl ? "✅" : "❌"}
|
variant={
|
||||||
|
importDialogInput === ""
|
||||||
|
? "default"
|
||||||
|
: validUrl
|
||||||
|
? "dirty"
|
||||||
|
: "invalid"
|
||||||
|
}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setImportDialogInput(e.target.value);
|
setImportDialogInput(e.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{validUrl && (
|
{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="flex w-full gap-2">
|
||||||
<div className="w-36">
|
<div className=" flex items-center">
|
||||||
<Label>{t("import.usePreset")}</Label>
|
|
||||||
<Switch
|
<Switch
|
||||||
disabled
|
className="ml-3 mr-4"
|
||||||
checked={channelSet?.loraConfig?.usePreset ?? true}
|
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>
|
</div>
|
||||||
{/* <Select
|
|
||||||
label="Modem Preset"
|
|
||||||
disabled
|
|
||||||
value={channelSet?.loraConfig?.modemPreset}
|
|
||||||
>
|
|
||||||
{renderOptions(Protobuf.Config_LoRaConfig_ModemPreset)}
|
|
||||||
</Select> */}
|
|
||||||
</div>
|
</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">
|
<div className="flex w-full flex-col gap-2">
|
||||||
{t("import.channels")}
|
<div className="flex items-center font-semibold text-sm">
|
||||||
</span>
|
<span className="flex-1">{t("import.channelName")}</span>
|
||||||
<div className="flex w-40 flex-col gap-1">
|
<span className="flex-1">{t("import.channelSlot")}</span>
|
||||||
{channelSet?.settings.map((channel) => (
|
</div>
|
||||||
<div className="flex justify-between" key={channel.id}>
|
{channelSet?.settings.map((channel, index) => (
|
||||||
<Label>
|
<div
|
||||||
|
className="flex items-center"
|
||||||
|
key={`channel_${channel.id}_${index}`}
|
||||||
|
>
|
||||||
|
<Label className="flex-1">
|
||||||
{channel.name.length
|
{channel.name.length
|
||||||
? channel.name
|
? channel.name
|
||||||
: `${t("import.channelPrefix")}${channel.id}`}
|
: `${t("import.channelPrefix")}${channel.id}`}
|
||||||
</Label>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const QRDialog = ({
|
|||||||
: `${t("page.channelIndex", {
|
: `${t("page.channelIndex", {
|
||||||
ns: "channels",
|
ns: "channels",
|
||||||
index: channel.index,
|
index: channel.index,
|
||||||
})}${channel.index}`}
|
})}`}
|
||||||
</Label>
|
</Label>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={channel.index}
|
key={channel.index}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
|
|||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||||
import { Subtle } from "@components/UI/Typography/Subtle.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 { useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
type Control,
|
type Control,
|
||||||
@@ -66,7 +64,6 @@ export interface DynamicFormProps<T extends FieldValues> {
|
|||||||
fields: FieldProps<T>[];
|
fields: FieldProps<T>[];
|
||||||
}[];
|
}[];
|
||||||
validationSchema?: ZodType<T>;
|
validationSchema?: ZodType<T>;
|
||||||
formId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DynamicFormFormInit<T extends FieldValues> = (
|
export type DynamicFormFormInit<T extends FieldValues> = (
|
||||||
@@ -83,10 +80,8 @@ export function DynamicForm<T extends FieldValues>({
|
|||||||
values,
|
values,
|
||||||
fieldGroups,
|
fieldGroups,
|
||||||
validationSchema,
|
validationSchema,
|
||||||
formId,
|
|
||||||
}: DynamicFormProps<T>) {
|
}: DynamicFormProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { addError, removeError } = useAppStore();
|
|
||||||
|
|
||||||
const internalMethods = useForm<T>({
|
const internalMethods = useForm<T>({
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
@@ -95,6 +90,7 @@ export function DynamicForm<T extends FieldValues>({
|
|||||||
? createZodResolver(validationSchema)
|
? createZodResolver(validationSchema)
|
||||||
: undefined,
|
: undefined,
|
||||||
shouldFocusError: false,
|
shouldFocusError: false,
|
||||||
|
shouldUnregister: true,
|
||||||
resetOptions: { keepDefaultValues: true },
|
resetOptions: { keepDefaultValues: true },
|
||||||
values,
|
values,
|
||||||
});
|
});
|
||||||
@@ -110,23 +106,6 @@ export function DynamicForm<T extends FieldValues>({
|
|||||||
}
|
}
|
||||||
}, [onFormInit, propMethods, internalMethods]);
|
}, [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 = (
|
const isDisabled = (
|
||||||
disabledBy?: DisabledBy<T>[],
|
disabledBy?: DisabledBy<T>[],
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import type {
|
|||||||
} from "@components/Form/DynamicForm.tsx";
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { Generator } from "@components/UI/Generator.tsx";
|
import { Generator } from "@components/UI/Generator.tsx";
|
||||||
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
||||||
import { useEffect } from "react";
|
import { Controller, type FieldValues } from "react-hook-form";
|
||||||
import { Controller, type FieldValues, useFormContext } from "react-hook-form";
|
|
||||||
import type { ButtonVariant } from "../UI/Button.tsx";
|
import type { ButtonVariant } from "../UI/Button.tsx";
|
||||||
|
|
||||||
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
||||||
@@ -34,36 +33,29 @@ export function PasswordGenerator<T extends FieldValues>({
|
|||||||
invalid,
|
invalid,
|
||||||
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
||||||
const { isVisible } = usePasswordVisibilityToggle();
|
const { isVisible } = usePasswordVisibilityToggle();
|
||||||
const { trigger } = useFormContext();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
trigger(field.name);
|
|
||||||
}, [field.name, trigger]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
name={field.name}
|
name={field.name}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange, ...rest } }) => (
|
render={({ field: controllerField }) => (
|
||||||
<Generator
|
<Generator
|
||||||
type={field.hide && !isVisible ? "password" : "text"}
|
type={field.hide && !isVisible ? "password" : "text"}
|
||||||
id={field.id}
|
id={field.id}
|
||||||
devicePSKBitCount={field.devicePSKBitCount}
|
devicePSKBitCount={field.devicePSKBitCount}
|
||||||
bits={field.bits}
|
bits={field.bits}
|
||||||
inputChange={(e) => {
|
inputChange={(e) => {
|
||||||
if (field.inputChange) {
|
const value = e.target.value;
|
||||||
field.inputChange(e);
|
field.inputChange?.(e); // call any external handler
|
||||||
}
|
controllerField.onChange(value); // ensure RHF receives just the value
|
||||||
onChange(e);
|
|
||||||
}}
|
}}
|
||||||
selectChange={field.selectChange ?? (() => {})}
|
selectChange={field.selectChange ?? (() => {})}
|
||||||
value={value}
|
|
||||||
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
|
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
|
||||||
actionButtons={field.actionButtons}
|
actionButtons={field.actionButtons}
|
||||||
showPasswordToggle={field.showPasswordToggle}
|
showPasswordToggle={field.showPasswordToggle}
|
||||||
showCopyButton={field.showCopyButton}
|
showCopyButton={field.showCopyButton}
|
||||||
{...field.properties}
|
{...field.properties}
|
||||||
{...rest}
|
{...controllerField}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
|
|
||||||
const handleValueChange = async (newValue: string) => {
|
const handleValueChange = async (newValue: string) => {
|
||||||
const selectedKey = valueToKeyMap[newValue];
|
const selectedKey = valueToKeyMap[newValue];
|
||||||
|
if (!selectedKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (field.validate) {
|
if (field.validate) {
|
||||||
const isValid = await field.validate(selectedKey);
|
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 { create } from "@bufbuild/protobuf";
|
||||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||||
import { useToast } from "@core/hooks/useToast.ts";
|
import {
|
||||||
|
DynamicForm,
|
||||||
|
type DynamicFormFormInit,
|
||||||
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores";
|
import { useDevice } from "@core/stores";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { fromByteArray, toByteArray } from "base64-js";
|
import { fromByteArray, toByteArray } from "base64-js";
|
||||||
import cryptoRandomString from "crypto-random-string";
|
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 { useTranslation } from "react-i18next";
|
||||||
import type { infer as zodInfer } from "zod/v4";
|
|
||||||
|
|
||||||
export interface SettingsPanelProps {
|
export interface SettingsPanelProps {
|
||||||
|
onFormInit: DynamicFormFormInit<ChannelValidation>;
|
||||||
channel: Protobuf.Channel.Channel;
|
channel: Protobuf.Channel.Channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Channel = ({ channel }: SettingsPanelProps) => {
|
export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||||
const { config, connection, addChannel } = useDevice();
|
const {
|
||||||
|
config,
|
||||||
|
setWorkingChannelConfig,
|
||||||
|
getWorkingChannelConfig,
|
||||||
|
removeWorkingChannelConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation(["channels", "ui", "dialog"]);
|
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] =
|
const [preSharedDialogOpen, setPreSharedDialogOpen] =
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
const [pass, setPass] = useState<string>(
|
|
||||||
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
|
||||||
);
|
|
||||||
const [byteCount, setBytes] = useState<number>(
|
const [byteCount, setBytes] = useState<number>(
|
||||||
channel?.settings?.psk.length ?? 16,
|
effectiveConfig?.settings?.psk.length ?? 16,
|
||||||
);
|
);
|
||||||
|
|
||||||
const ChannelValidationSchema = useMemo(() => {
|
const ChannelValidationSchema = useMemo(() => {
|
||||||
return makeChannelSchema(byteCount);
|
return makeChannelSchema(byteCount);
|
||||||
}, [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 onSubmit = (data: ChannelValidation) => {
|
||||||
const channel = create(Protobuf.Channel.ChannelSchema, {
|
if (!formState.isReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
...data,
|
...data,
|
||||||
settings: {
|
settings: {
|
||||||
...data.settings,
|
...data.settings,
|
||||||
psk: toByteArray(pass),
|
psk: toByteArray(data.settings.psk),
|
||||||
moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, {
|
moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, {
|
||||||
...data.settings.moduleSettings,
|
...data.settings.moduleSettings,
|
||||||
positionPrecision: data.settings.moduleSettings.positionPrecision,
|
positionPrecision: data.settings.moduleSettings.positionPrecision,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
connection?.setChannel(channel).then(() => {
|
|
||||||
console.debug(
|
if (deepCompareConfig(channel, payload, true)) {
|
||||||
t("toast.savedChannel.title", {
|
removeWorkingChannelConfig(channel.index);
|
||||||
ns: "ui",
|
return;
|
||||||
channelName: channel.settings?.name,
|
}
|
||||||
}),
|
|
||||||
);
|
setWorkingChannelConfig(create(Protobuf.Channel.ChannelSchema, payload));
|
||||||
toast({
|
|
||||||
title: t("toast.savedChannel.title", {
|
|
||||||
ns: "ui",
|
|
||||||
channelName: channel.settings?.name,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
addChannel(channel);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const preSharedKeyRegenerate = () => {
|
const preSharedKeyRegenerate = async () => {
|
||||||
const newPsk = btoa(
|
const newPsk = btoa(
|
||||||
cryptoRandomString({
|
cryptoRandomString({
|
||||||
length: byteCount ?? 0,
|
length: byteCount ?? 16,
|
||||||
type: "alphanumeric",
|
type: "alphanumeric",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setPass(newPsk);
|
setValue("settings.psk", newPsk, { shouldDirty: true });
|
||||||
|
|
||||||
setPreSharedDialogOpen(false);
|
setPreSharedDialogOpen(false);
|
||||||
};
|
|
||||||
|
|
||||||
const preSharedClickEvent = () => {
|
const valid = await trigger("settings.psk");
|
||||||
setPreSharedDialogOpen(true);
|
if (valid) {
|
||||||
};
|
handleSubmit(onSubmit)(); // manually invoke form submit
|
||||||
|
}
|
||||||
const inputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setPass(e.currentTarget?.value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectChangeEvent = (e: string) => {
|
const selectChangeEvent = (e: string) => {
|
||||||
const count = Number.parseInt(e);
|
const count = Number.parseInt(e);
|
||||||
setBytes(count);
|
if (!Number.isNaN(count)) {
|
||||||
|
setBytes(count);
|
||||||
|
trigger("settings.psk");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicForm<ChannelValidation>
|
<DynamicForm<ChannelValidation>
|
||||||
|
propMethods={formMethods}
|
||||||
onSubmit={onSubmit}
|
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={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("settings.label"),
|
label: t("settings.label"),
|
||||||
@@ -140,19 +187,17 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
id: "channel-psk",
|
id: "channel-psk",
|
||||||
label: t("psk.label"),
|
label: t("psk.label"),
|
||||||
description: t("psk.description"),
|
description: t("psk.description"),
|
||||||
devicePSKBitCount: byteCount ?? 0,
|
devicePSKBitCount: byteCount ?? 16,
|
||||||
inputChange: inputChangeEvent,
|
|
||||||
selectChange: selectChangeEvent,
|
selectChange: selectChangeEvent,
|
||||||
actionButtons: [
|
actionButtons: [
|
||||||
{
|
{
|
||||||
text: t("psk.generate"),
|
text: t("psk.generate"),
|
||||||
variant: "success",
|
variant: "success",
|
||||||
onClick: preSharedClickEvent,
|
onClick: () => setPreSharedDialogOpen(true),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hide: true,
|
hide: true,
|
||||||
properties: {
|
properties: {
|
||||||
value: pass,
|
|
||||||
showPasswordToggle: true,
|
showPasswordToggle: true,
|
||||||
showCopyButton: true,
|
showCopyButton: true,
|
||||||
},
|
},
|
||||||
@@ -44,7 +44,6 @@ export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={BluetoothValidationSchema}
|
validationSchema={BluetoothValidationSchema}
|
||||||
formId="Config_BluetoothConfig"
|
|
||||||
defaultValues={config.bluetooth}
|
defaultValues={config.bluetooth}
|
||||||
values={getEffectiveConfig("bluetooth")}
|
values={getEffectiveConfig("bluetooth")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export const Device = ({ onFormInit }: DeviceConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={DeviceValidationSchema}
|
validationSchema={DeviceValidationSchema}
|
||||||
formId="Config_DeviceConfig"
|
|
||||||
defaultValues={config.device}
|
defaultValues={config.device}
|
||||||
values={getEffectiveConfig("device")}
|
values={getEffectiveConfig("device")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export const Display = ({ onFormInit }: DisplayConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={DisplayValidationSchema}
|
validationSchema={DisplayValidationSchema}
|
||||||
formId="Config_DisplayConfig"
|
|
||||||
defaultValues={config.display}
|
defaultValues={config.display}
|
||||||
values={getEffectiveConfig("display")}
|
values={getEffectiveConfig("display")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={LoRaValidationSchema}
|
validationSchema={LoRaValidationSchema}
|
||||||
formId="Config_LoRaConfig"
|
|
||||||
defaultValues={config.lora}
|
defaultValues={config.lora}
|
||||||
values={getEffectiveConfig("lora")}
|
values={getEffectiveConfig("lora")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export const Network = ({ onFormInit }: NetworkConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={NetworkValidationSchema}
|
validationSchema={NetworkValidationSchema}
|
||||||
formId="Config_NetworkConfig"
|
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
...config.network,
|
...config.network,
|
||||||
ipv4Config: {
|
ipv4Config: {
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
|
|||||||
}}
|
}}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={PositionValidationSchema}
|
validationSchema={PositionValidationSchema}
|
||||||
formId="Config_PositionConfig"
|
|
||||||
defaultValues={config.position}
|
defaultValues={config.position}
|
||||||
values={getEffectiveConfig("position")}
|
values={getEffectiveConfig("position")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const Power = ({ onFormInit }: PowerConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={PowerValidationSchema}
|
validationSchema={PowerValidationSchema}
|
||||||
formId="Config_PowerConfig"
|
|
||||||
defaultValues={config.power}
|
defaultValues={config.power}
|
||||||
values={getEffectiveConfig("power")}
|
values={getEffectiveConfig("power")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
DynamicForm,
|
DynamicForm,
|
||||||
type DynamicFormFormInit,
|
type DynamicFormFormInit,
|
||||||
} from "@components/Form/DynamicForm.tsx";
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { useAppStore, useDevice } from "@core/stores";
|
import { useDevice } from "@core/stores";
|
||||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
@@ -35,21 +35,34 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
|
|||||||
removeWorkingConfig,
|
removeWorkingConfig,
|
||||||
} = useDevice();
|
} = useDevice();
|
||||||
|
|
||||||
const { removeError } = useAppStore();
|
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const securityConfig = getEffectiveConfig("security");
|
const defaultConfig = config.security;
|
||||||
const defaultValues = {
|
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(
|
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: [
|
adminKey: [
|
||||||
fromByteArray(securityConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
|
fromByteArray(effectiveConfig?.adminKey?.at(0) ?? new Uint8Array(0)),
|
||||||
fromByteArray(securityConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
|
fromByteArray(effectiveConfig?.adminKey?.at(1) ?? new Uint8Array(0)),
|
||||||
fromByteArray(securityConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
|
fromByteArray(effectiveConfig?.adminKey?.at(2) ?? new Uint8Array(0)),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -60,8 +73,9 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
|
|||||||
resolver: createZodResolver(RawSecuritySchema),
|
resolver: createZodResolver(RawSecuritySchema),
|
||||||
shouldFocusError: false,
|
shouldFocusError: false,
|
||||||
resetOptions: { keepDefaultValues: true },
|
resetOptions: { keepDefaultValues: true },
|
||||||
|
values: formValues as RawSecurity,
|
||||||
});
|
});
|
||||||
const { setValue, formState } = formMethods;
|
const { setValue, trigger, handleSubmit, formState } = formMethods;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onFormInit?.(formMethods);
|
onFormInit?.(formMethods);
|
||||||
@@ -108,19 +122,21 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => {
|
|||||||
updatePublicKey(fromByteArray(privateKey));
|
updatePublicKey(fromByteArray(privateKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePublicKey = (privateKey: string) => {
|
const updatePublicKey = async (privateKey: string) => {
|
||||||
try {
|
try {
|
||||||
const publicKey = fromByteArray(
|
const publicKey = fromByteArray(
|
||||||
getX25519PublicKey(toByteArray(privateKey)),
|
getX25519PublicKey(toByteArray(privateKey)),
|
||||||
);
|
);
|
||||||
setValue("privateKey", privateKey);
|
setValue("privateKey", privateKey, { shouldDirty: true });
|
||||||
setValue("publicKey", publicKey);
|
setValue("publicKey", publicKey, { shouldDirty: true });
|
||||||
|
|
||||||
removeError("privateKey");
|
|
||||||
removeError("publicKey");
|
|
||||||
setPrivateKeyDialogOpen(false);
|
setPrivateKeyDialogOpen(false);
|
||||||
} catch (_e) {
|
} 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>
|
<DynamicForm<RawSecurity>
|
||||||
propMethods={formMethods}
|
propMethods={formMethods}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
formId="Config_SecurityConfig"
|
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("security.title"),
|
label: t("security.title"),
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export const AmbientLighting = ({
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={AmbientLightingValidationSchema}
|
validationSchema={AmbientLightingValidationSchema}
|
||||||
formId="ModuleConfig_AmbientLightingConfig"
|
|
||||||
defaultValues={moduleConfig.ambientLighting}
|
defaultValues={moduleConfig.ambientLighting}
|
||||||
values={getEffectiveModuleConfig("ambientLighting")}
|
values={getEffectiveModuleConfig("ambientLighting")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={AudioValidationSchema}
|
validationSchema={AudioValidationSchema}
|
||||||
formId="ModuleConfig_AudioConfig"
|
|
||||||
defaultValues={moduleConfig.audio}
|
defaultValues={moduleConfig.audio}
|
||||||
values={getEffectiveModuleConfig("audio")}
|
values={getEffectiveModuleConfig("audio")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export const CannedMessage = ({
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={CannedMessageValidationSchema}
|
validationSchema={CannedMessageValidationSchema}
|
||||||
formId="ModuleConfig_CannedMessageConfig"
|
|
||||||
defaultValues={moduleConfig.cannedMessage}
|
defaultValues={moduleConfig.cannedMessage}
|
||||||
values={getEffectiveModuleConfig("cannedMessage")}
|
values={getEffectiveModuleConfig("cannedMessage")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export const DetectionSensor = ({
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={DetectionSensorValidationSchema}
|
validationSchema={DetectionSensorValidationSchema}
|
||||||
formId="ModuleConfig_DetectionSensorConfig"
|
|
||||||
defaultValues={moduleConfig.detectionSensor}
|
defaultValues={moduleConfig.detectionSensor}
|
||||||
values={getEffectiveModuleConfig("detectionSensor")}
|
values={getEffectiveModuleConfig("detectionSensor")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export const ExternalNotification = ({
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={ExternalNotificationValidationSchema}
|
validationSchema={ExternalNotificationValidationSchema}
|
||||||
formId="ModuleConfig_ExternalNotificationConfig"
|
|
||||||
defaultValues={moduleConfig.externalNotification}
|
defaultValues={moduleConfig.externalNotification}
|
||||||
values={getEffectiveModuleConfig("externalNotification")}
|
values={getEffectiveModuleConfig("externalNotification")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={MqttValidationSchema}
|
validationSchema={MqttValidationSchema}
|
||||||
formId="ModuleConfig_MqttConfig"
|
|
||||||
defaultValues={populateDefaultValues(moduleConfig.mqtt)}
|
defaultValues={populateDefaultValues(moduleConfig.mqtt)}
|
||||||
values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))}
|
values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={NeighborInfoValidationSchema}
|
validationSchema={NeighborInfoValidationSchema}
|
||||||
formId="ModuleConfig_NeighborInfoConfig"
|
|
||||||
defaultValues={moduleConfig.neighborInfo}
|
defaultValues={moduleConfig.neighborInfo}
|
||||||
values={getEffectiveModuleConfig("neighborInfo")}
|
values={getEffectiveModuleConfig("neighborInfo")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={PaxcounterValidationSchema}
|
validationSchema={PaxcounterValidationSchema}
|
||||||
formId="ModuleConfig_PaxcounterConfig"
|
|
||||||
defaultValues={moduleConfig.paxcounter}
|
defaultValues={moduleConfig.paxcounter}
|
||||||
values={getEffectiveModuleConfig("paxcounter")}
|
values={getEffectiveModuleConfig("paxcounter")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={RangeTestValidationSchema}
|
validationSchema={RangeTestValidationSchema}
|
||||||
formId="ModuleConfig_RangeTestConfig"
|
|
||||||
defaultValues={moduleConfig.rangeTest}
|
defaultValues={moduleConfig.rangeTest}
|
||||||
values={getEffectiveModuleConfig("rangeTest")}
|
values={getEffectiveModuleConfig("rangeTest")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={SerialValidationSchema}
|
validationSchema={SerialValidationSchema}
|
||||||
formId="ModuleConfig_SerialConfig"
|
|
||||||
defaultValues={moduleConfig.serial}
|
defaultValues={moduleConfig.serial}
|
||||||
values={getEffectiveModuleConfig("serial")}
|
values={getEffectiveModuleConfig("serial")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={StoreForwardValidationSchema}
|
validationSchema={StoreForwardValidationSchema}
|
||||||
formId="ModuleConfig_StoreForwardConfig"
|
|
||||||
defaultValues={moduleConfig.storeForward}
|
defaultValues={moduleConfig.storeForward}
|
||||||
values={getEffectiveModuleConfig("storeForward")}
|
values={getEffectiveModuleConfig("storeForward")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onFormInit={onFormInit}
|
onFormInit={onFormInit}
|
||||||
validationSchema={TelemetryValidationSchema}
|
validationSchema={TelemetryValidationSchema}
|
||||||
formId="ModuleConfig_TelemetryConfig"
|
|
||||||
defaultValues={moduleConfig.telemetry}
|
defaultValues={moduleConfig.telemetry}
|
||||||
values={getEffectiveModuleConfig("telemetry")}
|
values={getEffectiveModuleConfig("telemetry")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { cn } from "@core/utils/cn.ts";
|
|||||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
CircleChevronLeft,
|
CircleChevronLeft,
|
||||||
LayersIcon,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
MapIcon,
|
MapIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
@@ -113,11 +112,6 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
icon: SettingsIcon,
|
icon: SettingsIcon,
|
||||||
page: "config",
|
page: "config",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: t("navigation.channels"),
|
|
||||||
icon: LayersIcon,
|
|
||||||
page: "channels",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: `${t("navigation.nodes")} (${displayedNodeCount})`,
|
name: `${t("navigation.nodes")} (${displayedNodeCount})`,
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@components/UI/Select.tsx";
|
} from "@components/UI/Select.tsx";
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface ActionButton {
|
export interface ActionButton {
|
||||||
@@ -48,7 +47,6 @@ const Generator = ({
|
|||||||
showCopyButton,
|
showCopyButton,
|
||||||
...props
|
...props
|
||||||
}: GeneratorProps) => {
|
}: GeneratorProps) => {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const passwordRequiredBitSize = bits
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -102,7 +83,6 @@ const Generator = ({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={inputChange}
|
onChange={inputChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
ref={inputRef}
|
|
||||||
showCopyButton={showCopyButton}
|
showCopyButton={showCopyButton}
|
||||||
showPasswordToggle={showPasswordToggle}
|
showPasswordToggle={showPasswordToggle}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ export interface RasterSource {
|
|||||||
tileSize: number;
|
tileSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorState {
|
|
||||||
field: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
selectedDeviceId: number;
|
selectedDeviceId: number;
|
||||||
devices: {
|
devices: {
|
||||||
@@ -24,7 +19,6 @@ interface AppState {
|
|||||||
nodeNumToBeRemoved: number;
|
nodeNumToBeRemoved: number;
|
||||||
connectDialogOpen: boolean;
|
connectDialogOpen: boolean;
|
||||||
nodeNumDetails: number;
|
nodeNumDetails: number;
|
||||||
errors: ErrorState[];
|
|
||||||
|
|
||||||
setRasterSources: (sources: RasterSource[]) => void;
|
setRasterSources: (sources: RasterSource[]) => void;
|
||||||
addRasterSource: (source: RasterSource) => void;
|
addRasterSource: (source: RasterSource) => void;
|
||||||
@@ -36,15 +30,6 @@ interface AppState {
|
|||||||
setNodeNumToBeRemoved: (nodeNum: number) => void;
|
setNodeNumToBeRemoved: (nodeNum: number) => void;
|
||||||
setConnectDialogOpen: (open: boolean) => void;
|
setConnectDialogOpen: (open: boolean) => void;
|
||||||
setNodeNumDetails: (nodeNum: number) => 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) => ({
|
export const useAppStore = create<AppState>()((set, get) => ({
|
||||||
@@ -56,7 +41,6 @@ export const useAppStore = create<AppState>()((set, get) => ({
|
|||||||
connectDialogOpen: false,
|
connectDialogOpen: false,
|
||||||
nodeNumToBeRemoved: 0,
|
nodeNumToBeRemoved: 0,
|
||||||
nodeNumDetails: 0,
|
nodeNumDetails: 0,
|
||||||
errors: [],
|
|
||||||
|
|
||||||
setRasterSources: (sources: RasterSource[]) => {
|
setRasterSources: (sources: RasterSource[]) => {
|
||||||
set(
|
set(
|
||||||
@@ -114,47 +98,4 @@ export const useAppStore = create<AppState>()((set, get) => ({
|
|||||||
set(() => ({
|
set(() => ({
|
||||||
nodeNumDetails: nodeNum,
|
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,
|
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
|
||||||
workingConfig: [],
|
workingConfig: [],
|
||||||
workingModuleConfig: [],
|
workingModuleConfig: [],
|
||||||
|
workingChannelConfig: [],
|
||||||
hardware: {} as Protobuf.Mesh.MyNodeInfo,
|
hardware: {} as Protobuf.Mesh.MyNodeInfo,
|
||||||
metadata: new Map(),
|
metadata: new Map(),
|
||||||
traceroutes: new Map(),
|
traceroutes: new Map(),
|
||||||
@@ -57,6 +58,9 @@ export const mockDeviceStore: Device = {
|
|||||||
removeWorkingModuleConfig: vi.fn(),
|
removeWorkingModuleConfig: vi.fn(),
|
||||||
getEffectiveConfig: vi.fn(),
|
getEffectiveConfig: vi.fn(),
|
||||||
getEffectiveModuleConfig: vi.fn(),
|
getEffectiveModuleConfig: vi.fn(),
|
||||||
|
setWorkingChannelConfig: vi.fn(),
|
||||||
|
getWorkingChannelConfig: vi.fn(),
|
||||||
|
removeWorkingChannelConfig: vi.fn(),
|
||||||
setHardware: vi.fn(),
|
setHardware: vi.fn(),
|
||||||
setActiveNode: vi.fn(),
|
setActiveNode: vi.fn(),
|
||||||
setPendingSettingsChanges: vi.fn(),
|
setPendingSettingsChanges: vi.fn(),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface Device {
|
|||||||
moduleConfig: Protobuf.LocalOnly.LocalModuleConfig;
|
moduleConfig: Protobuf.LocalOnly.LocalModuleConfig;
|
||||||
workingConfig: Protobuf.Config.Config[];
|
workingConfig: Protobuf.Config.Config[];
|
||||||
workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[];
|
workingModuleConfig: Protobuf.ModuleConfig.ModuleConfig[];
|
||||||
|
workingChannelConfig: Protobuf.Channel.Channel[];
|
||||||
hardware: Protobuf.Mesh.MyNodeInfo;
|
hardware: Protobuf.Mesh.MyNodeInfo;
|
||||||
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
|
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
|
||||||
traceroutes: Map<
|
traceroutes: Map<
|
||||||
@@ -85,6 +86,11 @@ export interface Device {
|
|||||||
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
|
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
|
||||||
payloadVariant: K,
|
payloadVariant: K,
|
||||||
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
|
): 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;
|
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
|
||||||
setActiveNode: (node: number) => void;
|
setActiveNode: (node: number) => void;
|
||||||
setPendingSettingsChanges: (state: boolean) => void;
|
setPendingSettingsChanges: (state: boolean) => void;
|
||||||
@@ -139,6 +145,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema),
|
moduleConfig: create(Protobuf.LocalOnly.LocalModuleConfigSchema),
|
||||||
workingConfig: [],
|
workingConfig: [],
|
||||||
workingModuleConfig: [],
|
workingModuleConfig: [],
|
||||||
|
workingChannelConfig: [],
|
||||||
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
|
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
|
||||||
metadata: new Map(),
|
metadata: new Map(),
|
||||||
traceroutes: 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) => {
|
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
|
||||||
set(
|
set(
|
||||||
produce<PrivateDeviceState>((draft) => {
|
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 (
|
return (
|
||||||
<Tabs defaultValue={t("page.tabDevice")}>
|
<Tabs defaultValue={t("page.tabDevice")}>
|
||||||
<TabsList className="dark:bg-slate-700">
|
<TabsList className="w-full dark:bg-slate-700">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={tab.label}
|
key={tab.label}
|
||||||
@@ -103,9 +103,7 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
|
|||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsContent key={tab.label} value={tab.label}>
|
<TabsContent key={tab.label} value={tab.label}>
|
||||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
<tab.element onFormInit={onFormInit} />
|
||||||
<tab.element onFormInit={onFormInit} />
|
|
||||||
</Suspense>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue={t("page.tabMqtt")}>
|
<Tabs defaultValue={t("page.tabMqtt")}>
|
||||||
<TabsList className="dark:bg-slate-800">
|
<TabsList className="w-full dark:bg-slate-800">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={tab.label}
|
key={tab.label}
|
||||||
|
|||||||
@@ -3,18 +3,20 @@ import { Sidebar } from "@components/Sidebar.tsx";
|
|||||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
||||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||||
import { useToast } from "@core/hooks/useToast.ts";
|
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 { cn } from "@core/utils/cn.ts";
|
||||||
|
import { ChannelConfig } from "@pages/Config/ChannelConfig.tsx";
|
||||||
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
|
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
|
||||||
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
|
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
|
||||||
import {
|
import {
|
||||||
BoxesIcon,
|
BoxesIcon,
|
||||||
|
LayersIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
SaveOff,
|
SaveOff,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
} from "lucide-react";
|
} 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 type { FieldValues, UseFormReturn } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -22,18 +24,22 @@ const ConfigPage = () => {
|
|||||||
const {
|
const {
|
||||||
workingConfig,
|
workingConfig,
|
||||||
workingModuleConfig,
|
workingModuleConfig,
|
||||||
|
workingChannelConfig,
|
||||||
connection,
|
connection,
|
||||||
removeWorkingConfig,
|
removeWorkingConfig,
|
||||||
removeWorkingModuleConfig,
|
removeWorkingModuleConfig,
|
||||||
|
removeWorkingChannelConfig,
|
||||||
setConfig,
|
setConfig,
|
||||||
setModuleConfig,
|
setModuleConfig,
|
||||||
|
addChannel,
|
||||||
} = useDevice();
|
} = useDevice();
|
||||||
const { hasErrors } = useAppStore();
|
|
||||||
|
|
||||||
const [activeConfigSection, setActiveConfigSection] = useState<
|
const [activeConfigSection, setActiveConfigSection] = useState<
|
||||||
"device" | "module"
|
"device" | "module" | "channel"
|
||||||
>("device");
|
>("device");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
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 [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
@@ -41,21 +47,51 @@ const ConfigPage = () => {
|
|||||||
const onFormInit = useCallback(
|
const onFormInit = useCallback(
|
||||||
<T extends FieldValues>(methods: UseFormReturn<T>) => {
|
<T extends FieldValues>(methods: UseFormReturn<T>) => {
|
||||||
setFormMethods(methods as UseFormReturn);
|
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 () => {
|
// Cleanup subscription on unmount
|
||||||
if (hasErrors()) {
|
useEffect(() => {
|
||||||
return toast({
|
return () => unsubRef.current?.();
|
||||||
title: t("toast.validationError.title"),
|
}, []);
|
||||||
description: t("toast.validationError.description"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
try {
|
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(
|
await Promise.all(
|
||||||
workingConfig.map((newConfig) =>
|
workingConfig.map((newConfig) =>
|
||||||
connection?.setConfig(newConfig).then(() => {
|
connection?.setConfig(newConfig).then(() => {
|
||||||
@@ -82,24 +118,31 @@ const ConfigPage = () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await connection?.commitEditSettings().then(() => {
|
if (workingConfig.length > 0 || workingModuleConfig.length > 0) {
|
||||||
if (formMethods) {
|
await connection?.commitEditSettings();
|
||||||
formMethods.reset(
|
}
|
||||||
{},
|
|
||||||
{
|
|
||||||
keepValues: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
workingConfig.map((newConfig) => setConfig(newConfig));
|
workingChannelConfig.forEach((newChannel) => addChannel(newChannel));
|
||||||
workingModuleConfig.map((newModuleConfig) =>
|
workingConfig.forEach((newConfig) => setConfig(newConfig));
|
||||||
setModuleConfig(newModuleConfig),
|
workingModuleConfig.forEach((newModuleConfig) =>
|
||||||
);
|
setModuleConfig(newModuleConfig),
|
||||||
|
);
|
||||||
|
|
||||||
removeWorkingConfig();
|
removeWorkingChannelConfig();
|
||||||
removeWorkingModuleConfig();
|
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) {
|
} catch (_error) {
|
||||||
toast({
|
toast({
|
||||||
title: t("toast.configSaveError.title"),
|
title: t("toast.configSaveError.title"),
|
||||||
@@ -107,29 +150,40 @@ const ConfigPage = () => {
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
toast({
|
||||||
|
title: t("toast.saveAllSuccess.title"),
|
||||||
|
description: t("toast.saveAllSuccess.description"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
hasErrors,
|
|
||||||
toast,
|
toast,
|
||||||
t,
|
t,
|
||||||
workingConfig,
|
workingConfig,
|
||||||
connection,
|
connection,
|
||||||
workingModuleConfig,
|
workingModuleConfig,
|
||||||
|
workingChannelConfig,
|
||||||
formMethods,
|
formMethods,
|
||||||
|
addChannel,
|
||||||
setConfig,
|
setConfig,
|
||||||
setModuleConfig,
|
setModuleConfig,
|
||||||
removeWorkingConfig,
|
removeWorkingConfig,
|
||||||
removeWorkingModuleConfig,
|
removeWorkingModuleConfig,
|
||||||
|
removeWorkingChannelConfig,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
if (formMethods) {
|
if (formMethods) {
|
||||||
formMethods.reset();
|
formMethods.reset();
|
||||||
}
|
}
|
||||||
|
removeWorkingChannelConfig();
|
||||||
removeWorkingConfig();
|
removeWorkingConfig();
|
||||||
removeWorkingModuleConfig();
|
removeWorkingModuleConfig();
|
||||||
}, [formMethods, removeWorkingConfig, removeWorkingModuleConfig]);
|
}, [
|
||||||
|
formMethods,
|
||||||
|
removeWorkingConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
removeWorkingChannelConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
const leftSidebar = useMemo(
|
const leftSidebar = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -151,35 +205,33 @@ const ConfigPage = () => {
|
|||||||
isDirty={workingModuleConfig.length > 0}
|
isDirty={workingModuleConfig.length > 0}
|
||||||
count={workingModuleConfig.length}
|
count={workingModuleConfig.length}
|
||||||
/>
|
/>
|
||||||
|
<SidebarButton
|
||||||
|
label={t("navigation.channelConfig")}
|
||||||
|
active={activeConfigSection === "channel"}
|
||||||
|
onClick={() => setActiveConfigSection("channel")}
|
||||||
|
Icon={LayersIcon}
|
||||||
|
isDirty={workingChannelConfig.length > 0}
|
||||||
|
count={workingChannelConfig.length}
|
||||||
|
/>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
),
|
),
|
||||||
[activeConfigSection, workingConfig, workingModuleConfig, t],
|
[
|
||||||
|
activeConfigSection,
|
||||||
|
workingConfig,
|
||||||
|
workingModuleConfig,
|
||||||
|
workingChannelConfig,
|
||||||
|
t,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const buttonOpacity = useMemo(() => {
|
const hasDrafts =
|
||||||
const isFormDirty = formMethods?.formState.isDirty ?? false;
|
workingConfig.length > 0 ||
|
||||||
const hasDirtyFields =
|
workingModuleConfig.length > 0 ||
|
||||||
(Object.keys(formMethods?.formState.dirtyFields ?? {}).length ?? 0) > 0;
|
workingChannelConfig.length > 0;
|
||||||
const hasWorkingConfig = workingConfig.length > 0;
|
const hasPending = hasDrafts || rhfState.isDirty;
|
||||||
const hasWorkingModuleConfig = workingModuleConfig.length > 0;
|
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
|
||||||
|
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
|
||||||
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 actions = useMemo(
|
const actions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -207,15 +259,13 @@ const ConfigPage = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "save",
|
key: "save",
|
||||||
icon: !isValid ? SaveOff : SaveIcon,
|
icon: !hasPending ? SaveOff : SaveIcon,
|
||||||
isLoading: isSaving,
|
isLoading: isSaving,
|
||||||
disabled:
|
disabled: saveDisabled,
|
||||||
isSaving ||
|
iconClasses:
|
||||||
!isValid ||
|
!rhfState.isValid && hasPending
|
||||||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
|
? "text-red-400 cursor-not-allowed"
|
||||||
iconClasses: !isValid
|
: "cursor-pointer",
|
||||||
? "text-red-400 cursor-not-allowed"
|
|
||||||
: "cursor-pointer",
|
|
||||||
className: cn([
|
className: cn([
|
||||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||||
"hover:dark:bg-slate-300 hover:dark:text-black",
|
"hover:dark:bg-slate-300 hover:dark:text-black",
|
||||||
@@ -227,10 +277,10 @@ const ConfigPage = () => {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
isSaving,
|
isSaving,
|
||||||
isValid,
|
hasPending,
|
||||||
|
rhfState.isValid,
|
||||||
|
saveDisabled,
|
||||||
buttonOpacity,
|
buttonOpacity,
|
||||||
workingConfig,
|
|
||||||
workingModuleConfig,
|
|
||||||
handleReset,
|
handleReset,
|
||||||
handleSave,
|
handleSave,
|
||||||
t,
|
t,
|
||||||
@@ -244,14 +294,18 @@ const ConfigPage = () => {
|
|||||||
label={
|
label={
|
||||||
activeConfigSection === "device"
|
activeConfigSection === "device"
|
||||||
? t("navigation.radioConfig")
|
? t("navigation.radioConfig")
|
||||||
: t("navigation.moduleConfig")
|
: activeConfigSection === "module"
|
||||||
|
? t("navigation.moduleConfig")
|
||||||
|
: t("navigation.channelConfig")
|
||||||
}
|
}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
>
|
>
|
||||||
{activeConfigSection === "device" ? (
|
{activeConfigSection === "device" ? (
|
||||||
<DeviceConfig onFormInit={onFormInit} />
|
<DeviceConfig onFormInit={onFormInit} />
|
||||||
) : (
|
) : activeConfigSection === "module" ? (
|
||||||
<ModuleConfig onFormInit={onFormInit} />
|
<ModuleConfig onFormInit={onFormInit} />
|
||||||
|
) : (
|
||||||
|
<ChannelConfig onFormInit={onFormInit} />
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
import { randId } from "@core/utils/randId.ts";
|
import { randId } from "@core/utils/randId.ts";
|
||||||
import { Protobuf, Types } from "@meshtastic/core";
|
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 { useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
||||||
import type { useAppStore, useMessageStore } from "@core/stores";
|
import type { useAppStore, useMessageStore } from "@core/stores";
|
||||||
import ChannelsPage from "@pages/Channels.tsx";
|
|
||||||
import ConfigPage from "@pages/Config/index.tsx";
|
import ConfigPage from "@pages/Config/index.tsx";
|
||||||
import { Dashboard } from "@pages/Dashboard/index.tsx";
|
import { Dashboard } from "@pages/Dashboard/index.tsx";
|
||||||
import MapPage from "@pages/Map/index.tsx";
|
import MapPage from "@pages/Map/index.tsx";
|
||||||
@@ -81,12 +80,6 @@ const configRoute = createRoute({
|
|||||||
component: ConfigPage,
|
component: ConfigPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const channelsRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/channels",
|
|
||||||
component: ChannelsPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodesRoute = createRoute({
|
const nodesRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/nodes",
|
path: "/nodes",
|
||||||
@@ -105,7 +98,6 @@ const routeTree = rootRoute.addChildren([
|
|||||||
messagesWithParamsRoute,
|
messagesWithParamsRoute,
|
||||||
mapRoute,
|
mapRoute,
|
||||||
configRoute,
|
configRoute,
|
||||||
channelsRoute,
|
|
||||||
nodesRoute,
|
nodesRoute,
|
||||||
dialogWithParamsRoute,
|
dialogWithParamsRoute,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -35,3 +35,6 @@ export function makeChannelSchema(allowedBytes: number) {
|
|||||||
role: RoleEnum,
|
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