Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/web/public/i18n/locales/en/channels.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
14 changes: 9 additions & 5 deletions packages/web/public/i18n/locales/en/dialog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down
7 changes: 6 additions & 1 deletion packages/web/public/i18n/locales/en/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
192 changes: 135 additions & 57 deletions packages/web/src/components/Dialog/ImportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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 (
Expand All @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/Dialog/QRDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const QRDialog = ({
: `${t("page.channelIndex", {
ns: "channels",
index: channel.index,
})}${channel.index}`}
})}`}
</Label>
<Checkbox
key={channel.index}
Expand Down
23 changes: 1 addition & 22 deletions packages/web/src/components/Form/DynamicForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,7 +64,6 @@ export interface DynamicFormProps<T extends FieldValues> {
fields: FieldProps<T>[];
}[];
validationSchema?: ZodType<T>;
formId?: string;
}

export type DynamicFormFormInit<T extends FieldValues> = (
Expand All @@ -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",
Expand All @@ -95,6 +90,7 @@ export function DynamicForm<T extends FieldValues>({
? createZodResolver(validationSchema)
: undefined,
shouldFocusError: false,
shouldUnregister: true,
resetOptions: { keepDefaultValues: true },
values,
});
Expand All @@ -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,
Expand Down
Loading