Browse Source

Config form improvements (#652)

* Config reset work WIP

* Config reset WIP

* Fix tests, tsc, linting

* Form reset adjustments

* Add ManagedModeDialog

* Remove debug logging

* Add Suspense

* Review fixes

---------

Co-authored-by: philon- <[email protected]>
pull/670/head
Jeremy Gallant 12 months ago
committed by GitHub
parent
commit
181c984b27
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 72
      src/components/Dialog/ManagedModeDialog.tsx
  2. 72
      src/components/Form/DynamicForm.tsx
  3. 26
      src/components/Form/DynamicFormField.tsx
  4. 3
      src/components/Form/FormInput.tsx
  5. 15
      src/components/Form/FormMultiSelect.tsx
  6. 10
      src/components/Form/FormPasswordGenerator.tsx
  7. 19
      src/components/Form/FormSelect.tsx
  8. 16
      src/components/Form/FormToggle.tsx
  9. 21
      src/components/PageComponents/Config/Bluetooth.tsx
  10. 37
      src/components/PageComponents/Config/ConfigSuspender.tsx
  11. 25
      src/components/PageComponents/Config/Device/index.tsx
  12. 21
      src/components/PageComponents/Config/Display.tsx
  13. 21
      src/components/PageComponents/Config/LoRa.tsx
  14. 65
      src/components/PageComponents/Config/Network/index.tsx
  15. 23
      src/components/PageComponents/Config/Position.tsx
  16. 21
      src/components/PageComponents/Config/Power.tsx
  17. 171
      src/components/PageComponents/Config/Security/Security.tsx
  18. 28
      src/components/PageComponents/ModuleConfig/AmbientLighting.tsx
  19. 26
      src/components/PageComponents/ModuleConfig/Audio.tsx
  20. 28
      src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  21. 28
      src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
  22. 28
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  23. 57
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  24. 26
      src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
  25. 26
      src/components/PageComponents/ModuleConfig/Paxcounter.tsx
  26. 26
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  27. 26
      src/components/PageComponents/ModuleConfig/Serial.tsx
  28. 26
      src/components/PageComponents/ModuleConfig/StoreForward.tsx
  29. 26
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  30. 56
      src/components/PageLayout.tsx
  31. 2
      src/components/UI/Generator.tsx
  32. 23
      src/components/UI/Input.tsx
  33. 7
      src/components/UI/Sidebar/SidebarButton.tsx
  34. 7
      src/core/stores/deviceStore.mock.ts
  35. 132
      src/core/stores/deviceStore.ts
  36. 60
      src/core/utils/deepCompareConfig.test.ts
  37. 58
      src/core/utils/deepCompareConfig.ts
  38. 1
      src/i18n/locales/en/common.json
  39. 5
      src/i18n/locales/en/dialog.json
  40. 54
      src/pages/Config/DeviceConfig.tsx
  41. 66
      src/pages/Config/ModuleConfig.tsx
  42. 187
      src/pages/Config/index.tsx
  43. 2
      src/validation/config/network.ts
  44. 4
      src/validation/config/security.test.ts
  45. 2
      src/validation/config/security.ts
  46. 4
      src/validation/moduleConfig/mqtt.ts

72
src/components/Dialog/ManagedModeDialog.tsx

@ -0,0 +1,72 @@
import { Button } from "@components/UI/Button.tsx";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { Trans, useTranslation } from "react-i18next";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { useState } from "react";
export interface ManagedModeDialogProps {
open: boolean;
onOpenChange: () => void;
onSubmit: () => void;
}
export const ManagedModeDialog = ({
open,
onOpenChange,
onSubmit,
}: ManagedModeDialogProps) => {
const { t } = useTranslation("dialog");
const [confirmState, setConfirmState] = useState(false);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogClose />
<DialogHeader>
<DialogTitle>{t("managedMode.title")}</DialogTitle>
<DialogDescription>
<Trans
i18nKey="managedMode.description"
components={{
"bold": <p className="font-bold inline" />,
}}
/>
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Checkbox
id="managedMode"
checked={confirmState}
onChange={() => setConfirmState(!confirmState)}
name="confirmUnderstanding"
>
<p className="dark:text-white pt-1">
{t("managedMode.confirmUnderstanding")}
</p>
</Checkbox>
</div>
<DialogFooter>
<Button
variant="destructive"
name="regenerate"
disabled={!confirmState}
onClick={() => {
setConfirmState(false);
onSubmit();
}}
>
{t("button.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

72
src/components/Form/DynamicForm.tsx

@ -14,6 +14,7 @@ import {
type Path,
type SubmitHandler,
useForm,
type UseFormReturn,
} from "react-hook-form";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { ZodType } from "zod/v4";
@ -44,13 +45,18 @@ export interface GenericFormElementProps<T extends FieldValues, Y> {
control: Control<T>;
disabled?: boolean;
field: Y;
isDirty?: boolean;
invalid?: boolean;
}
export interface DynamicFormProps<T extends FieldValues> {
propMethods?: UseFormReturn<T, T, T>;
onSubmit: SubmitHandler<T>;
onFormInit?: DynamicFormFormInit<T>;
submitType?: "onChange" | "onSubmit";
hasSubmitButton?: boolean;
defaultValues?: DefaultValues<T>;
values?: T;
fieldGroups: {
label: string;
description: string;
@ -63,11 +69,18 @@ export interface DynamicFormProps<T extends FieldValues> {
formId?: string;
}
export type DynamicFormFormInit<T extends FieldValues> = (
methods: UseFormReturn<T, T, T>,
) => void;
export function DynamicForm<T extends FieldValues>({
propMethods,
onSubmit,
onFormInit,
submitType = "onChange",
hasSubmitButton,
defaultValues,
values,
fieldGroups,
validationSchema,
formId,
@ -78,17 +91,29 @@ export function DynamicForm<T extends FieldValues>({
removeError,
} = useAppStore();
const methods = useForm<
T
>({
mode: "onChange",
defaultValues: defaultValues,
resolver: validationSchema
? createZodResolver(validationSchema)
: undefined,
shouldFocusError: false,
});
const { handleSubmit, control, getValues, formState } = methods;
let methods = propMethods;
if (!methods) {
methods = useForm<
T
>({
mode: "onChange",
defaultValues: defaultValues,
resolver: validationSchema
? createZodResolver(validationSchema)
: undefined,
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
values,
});
}
const { handleSubmit, control, getValues, formState, getFieldState } =
methods;
useEffect(() => {
if (!propMethods) {
onFormInit?.(methods);
}
}, [onFormInit, propMethods, methods]);
useEffect(() => {
const errorKeys = Object.keys(formState.errors);
@ -155,25 +180,22 @@ export function DynamicForm<T extends FieldValues>({
label={field.label}
fieldName={field.name}
description={field.description}
valid={validationSchema // keep backwards compat with not updated cfg pages
? !error
: field.validationText === undefined ||
field.validationText === ""}
validationText={validationSchema
? (error
? String(
t([`formValidation.${error.type}`, error.message], {
returnObjects: false,
...error.params,
}),
)
: "")
: field.validationText}
valid={!error}
validationText={error
? String(
t([`formValidation.${error.type}`, error.message], {
returnObjects: false,
...error.params,
}),
)
: ""}
>
<DynamicFormField
field={field}
control={control}
disabled={isDisabled(field.disabledBy, field.disabled)}
isDirty={getFieldState(field.name).isDirty}
invalid={getFieldState(field.name).invalid}
/>
</FieldWrapper>
);

26
src/components/Form/DynamicFormField.tsx

@ -31,19 +31,29 @@ export interface DynamicFormFieldProps<T extends FieldValues> {
field: FieldProps<T>;
control: Control<T>;
disabled?: boolean;
isDirty?: boolean;
invalid?: boolean;
}
export function DynamicFormField<T extends FieldValues>({
field,
control,
disabled,
isDirty,
invalid,
}: DynamicFormFieldProps<T>) {
switch (field.type) {
case "text":
case "password":
case "number":
return (
<GenericInput field={field} control={control} disabled={disabled} />
<GenericInput
field={field}
control={control}
disabled={disabled}
isDirty={isDirty}
invalid={invalid}
/>
);
case "toggle":
@ -52,6 +62,8 @@ export function DynamicFormField<T extends FieldValues>({
field={field}
control={control}
disabled={disabled}
isDirty={isDirty}
invalid={invalid}
/>
);
case "select":
@ -60,6 +72,8 @@ export function DynamicFormField<T extends FieldValues>({
field={field}
control={control}
disabled={disabled}
isDirty={isDirty}
invalid={invalid}
/>
);
case "passwordGenerator":
@ -68,11 +82,19 @@ export function DynamicFormField<T extends FieldValues>({
field={field}
control={control}
disabled={disabled}
isDirty={isDirty}
invalid={invalid}
/>
);
case "multiSelect":
return (
<MultiSelectInput field={field} control={control} disabled={disabled} />
<MultiSelectInput
field={field}
control={control}
disabled={disabled}
isDirty={isDirty}
invalid={invalid}
/>
);
}
}

3
src/components/Form/FormInput.tsx

@ -31,6 +31,8 @@ export function GenericInput<T extends FieldValues>({
control,
disabled,
field,
isDirty,
invalid,
}: GenericFormElementProps<T, InputFieldProps<T>>) {
const { fieldLength, ...restProperties } = field.properties || {};
const [currentLength, setCurrentLength] = useState<number>(
@ -78,6 +80,7 @@ export function GenericInput<T extends FieldValues>({
className={field.properties?.className}
{...restProperties}
disabled={disabled}
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
/>
{fieldLength?.showCharacterCount && fieldLength?.max && (

15
src/components/Form/FormMultiSelect.tsx

@ -6,6 +6,7 @@ import type { FieldValues } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx";
import { cn } from "@core/utils/cn.ts";
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "multiSelect";
@ -23,9 +24,11 @@ export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
export function MultiSelectInput<T extends FieldValues>({
field,
isDirty,
invalid,
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
const { t } = useTranslation("deviceConfig");
const { enumValue, ...remainingProperties } = field.properties;
const { enumValue, className, ...remainingProperties } = field.properties;
const isNewConfigStructure =
typeof Object.values(enumValue)[0] === "object" &&
@ -48,7 +51,15 @@ export function MultiSelectInput<T extends FieldValues>({
);
return (
<MultiSelect {...remainingProperties}>
<MultiSelect
className={cn([
className,
"rounded-md",
isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-5" : "",
invalid ? "focus:ring-red-500 ring-red-500 ring-2 ring-offset-5" : "",
])}
{...remainingProperties}
>
{optionsToRender.map((option) => {
return (
<MultiSelectItem

10
src/components/Form/FormPasswordGenerator.tsx

@ -30,6 +30,8 @@ export function PasswordGenerator<T extends FieldValues>({
control,
field,
disabled,
isDirty,
invalid,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const { isVisible } = usePasswordVisibilityToggle();
const { trigger } = useFormContext();
@ -42,7 +44,11 @@ export function PasswordGenerator<T extends FieldValues>({
<Controller
name={field.name}
control={control}
render={({ field: { value, onChange, ...rest } }) => (
render={(
{
field: { value, onChange, ...rest },
},
) => (
<Generator
type={field.hide && !isVisible ? "password" : "text"}
id={field.id}
@ -54,7 +60,7 @@ export function PasswordGenerator<T extends FieldValues>({
}}
selectChange={field.selectChange ?? (() => {})}
value={value}
variant={field.validationText ? "invalid" : "default"}
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
actionButtons={field.actionButtons}
showPasswordToggle={field.showPasswordToggle}
showCopyButton={field.showCopyButton}

19
src/components/Form/FormSelect.tsx

@ -10,6 +10,7 @@ import {
SelectValue,
} from "@components/UI/Select.tsx";
import { type FieldValues, useController } from "react-hook-form";
import { cn } from "@core/utils/cn.ts";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select";
@ -38,6 +39,8 @@ export function SelectInput<T extends FieldValues>({
control,
disabled,
field,
isDirty,
invalid,
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
const {
field: { value, onChange, ref, onBlur, ...rest },
@ -46,8 +49,13 @@ export function SelectInput<T extends FieldValues>({
control,
});
const { enumValue, formatEnumName, defaultValue, ...remainingProperties } =
field.properties;
const {
enumValue,
formatEnumName,
defaultValue,
className,
...remainingProperties
} = field.properties;
const valueToKeyMap: Record<string, string> = {};
const optionsEnumValues: [string, number][] = [];
@ -82,6 +90,13 @@ export function SelectInput<T extends FieldValues>({
>
<SelectTrigger
id={field.name}
className={cn([
className,
isDirty ? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-2" : "",
invalid
? "focus:ring-red-500 ring-red-500 ring-2 outline-offset-2"
: "",
])}
ref={ref}
onBlur={onBlur}
{...remainingProperties}

16
src/components/Form/FormToggle.tsx

@ -4,6 +4,7 @@ import type {
} from "@components/Form/DynamicForm.tsx";
import { Switch } from "@components/UI/Switch.tsx";
import { Controller, type FieldValues } from "react-hook-form";
import { cn } from "@core/utils/cn.ts";
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
type: "toggle";
@ -14,12 +15,16 @@ export function ToggleInput<T extends FieldValues>({
control,
disabled,
field,
isDirty,
invalid,
}: GenericFormElementProps<T, ToggleFieldProps<T>>) {
return (
<Controller
name={field.name}
control={control}
render={({ field: { value, onChange, ...rest } }) => (
render={(
{ field: { value, onChange, ...rest } },
) => (
<Switch
checked={value}
onCheckedChange={(v) => {
@ -29,6 +34,15 @@ export function ToggleInput<T extends FieldValues>({
id={field.name}
disabled={disabled}
{...field.properties}
className={cn([
field.properties?.className,
isDirty
? "focus:ring-sky-500 ring-sky-500 ring-2 ring-offset-2"
: "",
invalid
? "focus:ring-red-500 ring-red-500 ring-2 ring-offset-2"
: "",
])}
{...rest}
/>
)}

21
src/components/PageComponents/Config/Bluetooth.tsx

@ -3,16 +3,29 @@ import {
BluetoothValidationSchema,
} from "@app/validation/config/bluetooth.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
export const Bluetooth = () => {
const { config, setWorkingConfig } = useDevice();
interface BluetoothConfigProps {
onFormInit: DynamicFormFormInit<BluetoothValidation>;
}
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: BluetoothValidation) => {
if (deepCompareConfig(config.bluetooth, data, true)) {
removeWorkingConfig("bluetooth");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
@ -26,9 +39,11 @@ export const Bluetooth = () => {
return (
<DynamicForm<BluetoothValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={BluetoothValidationSchema}
formId="Config_BluetoothConfig"
defaultValues={config.bluetooth}
values={getEffectiveConfig("bluetooth")}
fieldGroups={[
{
label: t("bluetooth.title"),

37
src/components/PageComponents/Config/ConfigSuspender.tsx

@ -0,0 +1,37 @@
import {
useDevice,
ValidConfigType,
ValidModuleConfigType,
} from "@core/stores/deviceStore.ts";
import { useEffect, useState } from "react";
export function ConfigSuspender({
configCase,
moduleConfigCase,
children,
}: {
configCase?: ValidConfigType;
moduleConfigCase?: ValidModuleConfigType;
children: React.ReactNode;
}) {
const { config, moduleConfig } = useDevice();
let cfg = undefined;
if (configCase) {
cfg = config[configCase];
} else if (moduleConfigCase) {
cfg = moduleConfig[moduleConfigCase];
} else {
return children;
}
const [ready, setReady] = useState(() => cfg !== undefined);
useEffect(() => {
if (cfg !== undefined) setReady(true);
}, [cfg]);
if (!ready) throw new Promise(() => {}); // triggers suspense fallback
return children;
}

25
src/components/PageComponents/Config/Device/index.tsx

@ -3,18 +3,31 @@ import {
DeviceValidationSchema,
} from "@app/validation/config/device.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
export const Device = () => {
const { config, setWorkingConfig } = useDevice();
interface DeviceConfigProps {
onFormInit: DynamicFormFormInit<DeviceValidation>;
}
export const Device = ({ onFormInit }: DeviceConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const { validateRoleSelection } = useUnsafeRolesDialog();
const onSubmit = (data: DeviceValidation) => {
if (deepCompareConfig(config.device, data, true)) {
removeWorkingConfig("device");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
@ -24,12 +37,15 @@ export const Device = () => {
}),
);
};
return (
<DynamicForm<DeviceValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DeviceValidationSchema}
formId="Config_DeviceConfig"
defaultValues={config.device}
values={getEffectiveConfig("device")}
fieldGroups={[
{
label: t("device.title"),
@ -97,7 +113,8 @@ export const Device = () => {
properties: {
fieldLength: {
max: 64,
currentValueLength: config.device?.tzdef?.length,
currentValueLength: getEffectiveConfig("device")?.tzdef
?.length,
showCharacterCount: true,
},
},

21
src/components/PageComponents/Config/Display.tsx

@ -3,16 +3,29 @@ import {
DisplayValidationSchema,
} from "@app/validation/config/display.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
export const Display = () => {
const { config, setWorkingConfig } = useDevice();
interface DisplayConfigProps {
onFormInit: DynamicFormFormInit<DisplayValidation>;
}
export const Display = ({ onFormInit }: DisplayConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: DisplayValidation) => {
if (deepCompareConfig(config.display, data, true)) {
removeWorkingConfig("display");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
@ -26,9 +39,11 @@ export const Display = () => {
return (
<DynamicForm<DisplayValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DisplayValidationSchema}
formId="Config_DisplayConfig"
defaultValues={config.display}
values={getEffectiveConfig("display")}
fieldGroups={[
{
label: t("display.title"),

21
src/components/PageComponents/Config/LoRa.tsx

@ -3,16 +3,29 @@ import {
LoRaValidationSchema,
} from "@app/validation/config/lora.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
export const LoRa = () => {
const { config, setWorkingConfig } = useDevice();
interface LoRaConfigProps {
onFormInit: DynamicFormFormInit<LoRaValidation>;
}
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: LoRaValidation) => {
if (deepCompareConfig(config.lora, data, true)) {
removeWorkingConfig("lora");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
@ -26,9 +39,11 @@ export const LoRa = () => {
return (
<DynamicForm<LoRaValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={LoRaValidationSchema}
formId="Config_LoRaConfig"
defaultValues={config.lora}
values={getEffectiveConfig("lora")}
fieldGroups={[
{
label: t("lora.title"),

65
src/components/PageComponents/Config/Network/index.tsx

@ -3,7 +3,10 @@ import {
NetworkValidationSchema,
} from "@app/validation/config/network.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import {
convertIntToIpAddress,
@ -11,35 +14,50 @@ import {
} from "@core/utils/ip.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
export const Network = () => {
const { config, setWorkingConfig } = useDevice();
interface NetworkConfigProps {
onFormInit: DynamicFormFormInit<NetworkValidation>;
}
export const Network = ({ onFormInit }: NetworkConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const networkConfig = getEffectiveConfig("network");
const onSubmit = (data: NetworkValidation) => {
const payload = {
...data,
ipv4Config: create(
Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema,
{
ip: convertIpAddressToInt(data.ipv4Config?.ip ?? ""),
gateway: convertIpAddressToInt(data.ipv4Config?.gateway ?? ""),
subnet: convertIpAddressToInt(data.ipv4Config?.subnet ?? ""),
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
},
),
};
if (deepCompareConfig(config.network, payload, true)) {
removeWorkingConfig("network");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "network",
value: {
...data,
ipv4Config: create(
Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema,
{
ip: convertIpAddressToInt(data.ipv4Config?.ip ?? ""),
gateway: convertIpAddressToInt(data.ipv4Config?.gateway ?? ""),
subnet: convertIpAddressToInt(data.ipv4Config?.subnet ?? ""),
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
},
),
},
value: payload,
},
}),
);
};
return (
<DynamicForm<NetworkValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={NetworkValidationSchema}
formId="Config_NetworkConfig"
defaultValues={{
@ -57,6 +75,21 @@ export const Network = () => {
enabledProtocols: config.network?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
}}
values={{
...networkConfig,
ipv4Config: {
ip: convertIntToIpAddress(networkConfig?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(
networkConfig?.ipv4Config?.gateway ?? 0,
),
subnet: convertIntToIpAddress(
networkConfig?.ipv4Config?.subnet ?? 0,
),
dns: convertIntToIpAddress(networkConfig?.ipv4Config?.dns ?? 0),
},
enabledProtocols: networkConfig?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
} as NetworkValidation}
fieldGroups={[
{
label: t("network.title"),

23
src/components/PageComponents/Config/Position.tsx

@ -7,20 +7,33 @@ import {
PositionValidationSchema,
} from "@app/validation/config/position.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
export const Position = () => {
const { config, setWorkingConfig } = useDevice();
interface PositionConfigProps {
onFormInit: DynamicFormFormInit<PositionValidation>;
}
export const Position = ({ onFormInit }: PositionConfigProps) => {
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
config?.position?.positionFlags ?? 0,
getEffectiveConfig("position")?.positionFlags ?? 0,
);
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: PositionValidation) => {
if (deepCompareConfig(config.position, data, true)) {
removeWorkingConfig("position");
return;
}
return setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
@ -44,9 +57,11 @@ export const Position = () => {
data.positionFlags = flagsValue;
return onSubmit(data);
}}
onFormInit={onFormInit}
validationSchema={PositionValidationSchema}
formId="Config_PositionConfig"
defaultValues={config.position}
values={getEffectiveConfig("position")}
fieldGroups={[
{
label: t("position.title"),

21
src/components/PageComponents/Config/Power.tsx

@ -3,16 +3,29 @@ import {
PowerValidationSchema,
} from "@app/validation/config/power.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
export const Power = () => {
const { config, setWorkingConfig } = useDevice();
interface PowerConfigProps {
onFormInit: DynamicFormFormInit<PowerValidation>;
}
export const Power = ({ onFormInit }: PowerConfigProps) => {
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
useDevice();
const { t } = useTranslation("deviceConfig");
const onSubmit = (data: PowerValidation) => {
if (deepCompareConfig(config.power, data, true)) {
removeWorkingConfig("power");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
@ -26,9 +39,11 @@ export const Power = () => {
return (
<DynamicForm<PowerValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={PowerValidationSchema}
formId="Config_PowerConfig"
defaultValues={config.power}
values={getEffectiveConfig("power")}
fieldGroups={[
{
label: t("power.powerConfigSettings.label"),

171
src/components/PageComponents/Config/Security/Security.tsx

@ -1,5 +1,9 @@
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
import {
@ -7,37 +11,82 @@ import {
type RawSecurity,
RawSecuritySchema,
} from "@app/validation/config/security.ts";
import { useState } from "react";
import { useEffect, useState } from "react";
import { create } from "@bufbuild/protobuf";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { fromByteArray, toByteArray } from "base64-js";
import { useTranslation } from "react-i18next";
import { type DefaultValues, useForm } from "react-hook-form";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
type KeyState = {
publicKey: string;
privateKey: string;
privateKeyDialogOpen: boolean;
};
interface SecurityConfigProps {
onFormInit: DynamicFormFormInit<RawSecurity>;
}
export const Security = ({ onFormInit }: SecurityConfigProps) => {
const {
config,
setWorkingConfig,
setDialogOpen,
getEffectiveConfig,
removeWorkingConfig,
} = useDevice();
export const Security = () => {
const { config, setWorkingConfig, setDialogOpen } = useDevice();
const { removeError } = useAppStore();
const { t } = useTranslation("deviceConfig");
const [keyState, setKeyState] = useState<KeyState>(() => ({
publicKey: fromByteArray(config?.security?.publicKey ?? new Uint8Array(0)),
privateKey: fromByteArray(
config?.security?.privateKey ?? new Uint8Array(0),
),
privateKeyDialogOpen: false,
}));
const securityConfig = getEffectiveConfig("security");
const defaultValues = {
...securityConfig,
...{
privateKey: fromByteArray(
securityConfig?.privateKey ?? new Uint8Array(0),
),
publicKey: fromByteArray(
securityConfig?.publicKey ?? new Uint8Array(0),
),
adminKey: [
fromByteArray(
securityConfig?.adminKey?.at(0) ?? new Uint8Array(0),
),
fromByteArray(
securityConfig?.adminKey?.at(1) ?? new Uint8Array(0),
),
fromByteArray(
securityConfig?.adminKey?.at(2) ?? new Uint8Array(0),
),
],
},
};
const formMethods = useForm<RawSecurity>({
mode: "onChange",
defaultValues: defaultValues as DefaultValues<RawSecurity>,
resolver: createZodResolver(RawSecuritySchema),
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
});
const { setValue, formState } = formMethods;
useEffect(() => {
onFormInit?.(formMethods);
}, [onFormInit, formMethods]);
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState<boolean>(
false,
);
const [managedModeDialogOpen, setManagedModeDialogOpen] = useState<boolean>(
false,
);
const onSubmit = (data: RawSecurity) => {
if (!formState.isReady) return;
const payload: ParsedSecurity = {
...data,
privateKey: toByteArray(keyState.privateKey),
publicKey: toByteArray(keyState.publicKey),
privateKey: toByteArray(data.privateKey),
publicKey: toByteArray(data.publicKey),
adminKey: [
toByteArray(data.adminKey.at(0) ?? ""),
toByteArray(data.adminKey.at(1) ?? ""),
@ -45,6 +94,11 @@ export const Security = () => {
],
};
if (deepCompareConfig(config.security, payload, true)) {
removeWorkingConfig("security");
return;
}
setWorkingConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
@ -54,18 +108,10 @@ export const Security = () => {
}),
);
};
const pkiRegenerate = () => {
const privateKey = getX25519PrivateKey();
updatePublicKey(fromByteArray(privateKey));
setKeyState((prev) => ({
...prev,
privateKey: fromByteArray(privateKey),
privateKeyDialogOpen: false,
}));
removeError("privateKey");
};
const updatePublicKey = (privateKey: string) => {
@ -73,18 +119,14 @@ export const Security = () => {
const publicKey = fromByteArray(
getX25519PublicKey(toByteArray(privateKey)),
);
setKeyState((prev) => ({
...prev,
privateKey: privateKey,
publicKey: publicKey,
}));
setValue("privateKey", privateKey);
setValue("publicKey", publicKey);
removeError("privateKey");
removeError("publicKey");
setPrivateKeyDialogOpen(false);
} catch (_e) {
setKeyState((prev) => ({
...prev,
privateKey: privateKey,
}));
setValue("privateKey", privateKey);
}
};
@ -99,31 +141,9 @@ export const Security = () => {
return (
<>
<DynamicForm<RawSecurity>
propMethods={formMethods}
onSubmit={onSubmit}
validationSchema={RawSecuritySchema}
formId="Config_SecurityConfig"
defaultValues={{
...config.security,
...{
privateKey: fromByteArray(
config?.security?.privateKey ?? new Uint8Array(0),
),
publicKey: fromByteArray(
config?.security?.publicKey ?? new Uint8Array(0),
),
adminKey: [
fromByteArray(
config?.security?.adminKey.at(0) ?? new Uint8Array(0),
),
fromByteArray(
config?.security?.adminKey.at(1) ?? new Uint8Array(0),
),
fromByteArray(
config?.security?.adminKey.at(2) ?? new Uint8Array(0),
),
],
},
}}
fieldGroups={[
{
label: t("security.title"),
@ -144,11 +164,7 @@ export const Security = () => {
actionButtons: [
{
text: t("button.generate"),
onClick: () =>
setKeyState((prev) => ({
...prev,
privateKeyDialogOpen: true,
})),
onClick: () => setPrivateKeyDialogOpen(true),
variant: "success",
},
{
@ -160,8 +176,6 @@ export const Security = () => {
properties: {
showCopyButton: true,
showPasswordToggle: true,
value: keyState.privateKey,
},
},
{
@ -172,7 +186,6 @@ export const Security = () => {
description: t("security.publicKey.description"),
properties: {
showCopyButton: true,
value: keyState.publicKey,
},
},
],
@ -240,6 +253,13 @@ export const Security = () => {
name: "isManaged",
label: t("security.managed.label"),
description: t("security.managed.description"),
inputChange: (checked) => {
if (checked) {
setManagedModeDialogOpen(true);
}
setValue("isManaged", false);
},
},
{
type: "toggle",
@ -275,14 +295,19 @@ export const Security = () => {
title: t("pkiRegenerate.title"),
description: t("pkiRegenerate.description"),
}}
open={keyState.privateKeyDialogOpen}
onOpenChange={() =>
setKeyState((prev) => ({
...prev,
privateKeyDialogOpen: false,
}))}
open={privateKeyDialogOpen}
onOpenChange={() => setPrivateKeyDialogOpen((prev) => !prev)}
onSubmit={pkiRegenerate}
/>
<ManagedModeDialog
open={managedModeDialogOpen}
onOpenChange={() => setManagedModeDialogOpen((prev) => !prev)}
onSubmit={() => {
setValue("isManaged", true);
setManagedModeDialogOpen(false);
}}
/>
</>
);
};

28
src/components/PageComponents/ModuleConfig/AmbientLighting.tsx

@ -4,15 +4,35 @@ import {
AmbientLightingValidationSchema,
} from "@app/validation/moduleConfig/ambientLighting.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface AmbientLightingModuleConfigProps {
onFormInit: DynamicFormFormInit<AmbientLightingValidation>;
}
export const AmbientLighting = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
export const AmbientLighting = (
{ onFormInit }: AmbientLightingModuleConfigProps,
) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AmbientLightingValidation) => {
if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) {
removeWorkingModuleConfig("ambientLighting");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@ -26,9 +46,11 @@ export const AmbientLighting = () => {
return (
<DynamicForm<AmbientLightingValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={AmbientLightingValidationSchema}
formId="ModuleConfig_AmbientLightingConfig"
defaultValues={moduleConfig.ambientLighting}
values={getEffectiveModuleConfig("ambientLighting")}
fieldGroups={[
{
label: t("ambientLighting.title"),

26
src/components/PageComponents/ModuleConfig/Audio.tsx

@ -3,16 +3,34 @@ import {
AudioValidationSchema,
} from "@app/validation/moduleConfig/audio.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface AudioModuleConfigProps {
onFormInit: DynamicFormFormInit<AudioValidation>;
}
export const Audio = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: AudioValidation) => {
if (deepCompareConfig(moduleConfig.audio, data, true)) {
removeWorkingModuleConfig("audio");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@ -26,9 +44,11 @@ export const Audio = () => {
return (
<DynamicForm<AudioValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={AudioValidationSchema}
formId="ModuleConfig_AudioConfig"
defaultValues={moduleConfig.audio}
values={getEffectiveModuleConfig("audio")}
fieldGroups={[
{
label: t("audio.title"),

28
src/components/PageComponents/ModuleConfig/CannedMessage.tsx

@ -3,16 +3,36 @@ import {
CannedMessageValidationSchema,
} from "@app/validation/moduleConfig/cannedMessage.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface CannedMessageModuleConfigProps {
onFormInit: DynamicFormFormInit<CannedMessageValidation>;
}
export const CannedMessage = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
export const CannedMessage = (
{ onFormInit }: CannedMessageModuleConfigProps,
) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: CannedMessageValidation) => {
if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) {
removeWorkingModuleConfig("cannedMessage");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@ -26,9 +46,11 @@ export const CannedMessage = () => {
return (
<DynamicForm<CannedMessageValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={CannedMessageValidationSchema}
formId="ModuleConfig_CannedMessageConfig"
defaultValues={moduleConfig.cannedMessage}
values={getEffectiveModuleConfig("cannedMessage")}
fieldGroups={[
{
label: t("cannedMessage.title"),

28
src/components/PageComponents/ModuleConfig/DetectionSensor.tsx

@ -4,15 +4,35 @@ import {
DetectionSensorValidationSchema,
} from "@app/validation/moduleConfig/detectionSensor.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface DetectionSensorModuleConfigProps {
onFormInit: DynamicFormFormInit<DetectionSensorValidation>;
}
export const DetectionSensor = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
export const DetectionSensor = (
{ onFormInit }: DetectionSensorModuleConfigProps,
) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: DetectionSensorValidation) => {
if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) {
removeWorkingModuleConfig("detectionSensor");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@ -26,9 +46,11 @@ export const DetectionSensor = () => {
return (
<DynamicForm<DetectionSensorValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DetectionSensorValidationSchema}
formId="ModuleConfig_DetectionSensorConfig"
defaultValues={moduleConfig.detectionSensor}
values={getEffectiveModuleConfig("detectionSensor")}
fieldGroups={[
{
label: t("detectionSensor.title"),

28
src/components/PageComponents/ModuleConfig/ExternalNotification.tsx

@ -3,16 +3,36 @@ import {
ExternalNotificationValidationSchema,
} from "@app/validation/moduleConfig/externalNotification.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface ExternalNotificationModuleConfigProps {
onFormInit: DynamicFormFormInit<ExternalNotificationValidation>;
}
export const ExternalNotification = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
export const ExternalNotification = (
{ onFormInit }: ExternalNotificationModuleConfigProps,
) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: ExternalNotificationValidation) => {
if (deepCompareConfig(moduleConfig.externalNotification, data, true)) {
removeWorkingModuleConfig("externalNotification");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@ -26,9 +46,11 @@ export const ExternalNotification = () => {
return (
<DynamicForm<ExternalNotificationValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={ExternalNotificationValidationSchema}
formId="ModuleConfig_ExternalNotificationConfig"
defaultValues={moduleConfig.externalNotification}
values={getEffectiveModuleConfig("externalNotification")}
fieldGroups={[
{
label: t("externalNotification.title"),

57
src/components/PageComponents/ModuleConfig/MQTT.tsx

@ -4,37 +4,72 @@ import {
MqttValidationSchema,
} from "@app/validation/moduleConfig/mqtt.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface MqttModuleConfigProps {
onFormInit: DynamicFormFormInit<MqttValidation>;
}
export const MQTT = () => {
const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
const {
config,
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: MqttValidation) => {
const payload = {
...data,
mapReportSettings: create(
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
data.mapReportSettings,
),
};
if (deepCompareConfig(moduleConfig.mqtt, payload, true)) {
removeWorkingModuleConfig("mqtt");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "mqtt",
value: {
...data,
mapReportSettings: create(
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
data.mapReportSettings,
),
},
value: payload,
},
}),
);
};
const populateDefaultValues = (
cfg: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined,
) => {
return cfg
? {
...cfg,
mapReportSettings: cfg.mapReportSettings ??
{ publishIntervalSecs: 0, positionPrecision: 10 },
}
: undefined;
};
return (
<DynamicForm<MqttValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={MqttValidationSchema}
formId="ModuleConfig_MqttConfig"
defaultValues={moduleConfig.mqtt}
defaultValues={populateDefaultValues(moduleConfig.mqtt)}
values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))}
fieldGroups={[
{
label: t("mqtt.title"),

26
src/components/PageComponents/ModuleConfig/NeighborInfo.tsx

@ -4,15 +4,33 @@ import {
NeighborInfoValidationSchema,
} from "@app/validation/moduleConfig/neighborInfo.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface NeighborInfoModuleConfigProps {
onFormInit: DynamicFormFormInit<NeighborInfoValidation>;
}
export const NeighborInfo = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: NeighborInfoValidation) => {
if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) {
removeWorkingModuleConfig("neighborInfo");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@ -26,9 +44,11 @@ export const NeighborInfo = () => {
return (
<DynamicForm<NeighborInfoValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={NeighborInfoValidationSchema}
formId="ModuleConfig_NeighborInfoConfig"
defaultValues={moduleConfig.neighborInfo}
values={getEffectiveModuleConfig("neighborInfo")}
fieldGroups={[
{
label: t("neighborInfo.title"),

26
src/components/PageComponents/ModuleConfig/Paxcounter.tsx

@ -3,16 +3,34 @@ import {
PaxcounterValidationSchema,
} from "@app/validation/moduleConfig/paxcounter.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface PaxcounterModuleConfigProps {
onFormInit: DynamicFormFormInit<PaxcounterValidation>;
}
export const Paxcounter = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: PaxcounterValidation) => {
if (deepCompareConfig(moduleConfig.paxcounter, data, true)) {
removeWorkingModuleConfig("paxcounter");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@ -26,9 +44,11 @@ export const Paxcounter = () => {
return (
<DynamicForm<PaxcounterValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={PaxcounterValidationSchema}
formId="ModuleConfig_PaxcounterConfig"
defaultValues={moduleConfig.paxcounter}
values={getEffectiveModuleConfig("paxcounter")}
fieldGroups={[
{
label: t("paxcounter.title"),

26
src/components/PageComponents/ModuleConfig/RangeTest.tsx

@ -3,16 +3,34 @@ import {
RangeTestValidationSchema,
} from "@app/validation/moduleConfig/rangeTest.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface RangeTestModuleConfigProps {
onFormInit: DynamicFormFormInit<RangeTestValidation>;
}
export const RangeTest = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: RangeTestValidation) => {
if (deepCompareConfig(moduleConfig.rangeTest, data, true)) {
removeWorkingModuleConfig("rangeTest");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@ -26,9 +44,11 @@ export const RangeTest = () => {
return (
<DynamicForm<RangeTestValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={RangeTestValidationSchema}
formId="ModuleConfig_RangeTestConfig"
defaultValues={moduleConfig.rangeTest}
values={getEffectiveModuleConfig("rangeTest")}
fieldGroups={[
{
label: t("rangeTest.title"),

26
src/components/PageComponents/ModuleConfig/Serial.tsx

@ -3,16 +3,34 @@ import {
SerialValidationSchema,
} from "@app/validation/moduleConfig/serial.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface SerialModuleConfigProps {
onFormInit: DynamicFormFormInit<SerialValidation>;
}
export const Serial = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: SerialValidation) => {
if (deepCompareConfig(moduleConfig.serial, data, true)) {
removeWorkingModuleConfig("serial");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@ -26,9 +44,11 @@ export const Serial = () => {
return (
<DynamicForm<SerialValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={SerialValidationSchema}
formId="ModuleConfig_SerialConfig"
defaultValues={moduleConfig.serial}
values={getEffectiveModuleConfig("serial")}
fieldGroups={[
{
label: t("serial.title"),

26
src/components/PageComponents/ModuleConfig/StoreForward.tsx

@ -3,16 +3,34 @@ import {
StoreForwardValidationSchema,
} from "@app/validation/moduleConfig/storeForward.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface StoreForwardModuleConfigProps {
onFormInit: DynamicFormFormInit<StoreForwardValidation>;
}
export const StoreForward = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: StoreForwardValidation) => {
if (deepCompareConfig(moduleConfig.storeForward, data, true)) {
removeWorkingModuleConfig("storeForward");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@ -26,9 +44,11 @@ export const StoreForward = () => {
return (
<DynamicForm<StoreForwardValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={StoreForwardValidationSchema}
formId="ModuleConfig_StoreForwardConfig"
defaultValues={moduleConfig.storeForward}
values={getEffectiveModuleConfig("storeForward")}
fieldGroups={[
{
label: t("storeForward.title"),

26
src/components/PageComponents/ModuleConfig/Telemetry.tsx

@ -3,16 +3,34 @@ import {
TelemetryValidationSchema,
} from "@app/validation/moduleConfig/telemetry.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next";
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
interface TelemetryModuleConfigProps {
onFormInit: DynamicFormFormInit<TelemetryValidation>;
}
export const Telemetry = () => {
const { moduleConfig, setWorkingModuleConfig } = useDevice();
export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
const {
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: TelemetryValidation) => {
if (deepCompareConfig(moduleConfig.telemetry, data, true)) {
removeWorkingModuleConfig("telemetry");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
@ -26,9 +44,11 @@ export const Telemetry = () => {
return (
<DynamicForm<TelemetryValidation>
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={TelemetryValidationSchema}
formId="ModuleConfig_TelemetryConfig"
defaultValues={moduleConfig.telemetry}
values={getEffectiveModuleConfig("telemetry")}
fieldGroups={[
{
label: t("telemetry.title"),

56
src/components/PageLayout.tsx

@ -8,12 +8,14 @@ import { ErrorPage } from "@components/UI/ErrorPage.tsx";
export interface ActionItem {
key: string;
icon: LucideIcon;
icon?: LucideIcon;
iconClasses?: string;
onClick: () => void;
disabled?: boolean;
isLoading?: boolean;
ariaLabel?: string;
label?: string;
className?: string;
}
export interface PageLayoutProps {
@ -69,27 +71,39 @@ export const PageLayout = ({
<span className="text-lg font-medium text-foreground truncate px-2">
{label}
</span>
<div className="flex items-center space-x-3 md:space-x-4 shrink-0">
{actions?.map((action) => (
<button
key={action.key}
type="button"
disabled={action.disabled || action.isLoading}
className="text-foreground transition-colors hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed"
onClick={action.onClick}
aria-label={action.ariaLabel || `Action ${action.key}`}
aria-disabled={action.disabled}
aria-busy={action.isLoading}
>
<div className="mr-6">
{action.isLoading ? <Spinner size="md" /> : (
<action.icon
className={cn("h-5 w-5", action.iconClasses)}
/>
<div className="flex items-center space-x-1 md:space-x-2 shrink-0 pr-6">
{actions?.map((action) => {
return (
<button
key={action.key}
type="button"
disabled={action.disabled || action.isLoading}
className={cn(
"flex items-center space-x-2 py-2 px-3 rounded-md",
"text-foreground transition-colors hover:text-accent",
"hover:bg-slate-200 disabled:hover:bg-white",
"disabled:opacity-50 disabled:cursor-not-allowed",
action.className,
)}
</div>
</button>
))}
onClick={action.onClick}
aria-label={action.ariaLabel || `Action ${action.key}`}
aria-disabled={action.disabled}
aria-busy={action.isLoading}
>
{action.icon &&
(action.isLoading ? <Spinner size="md" /> : (
<action.icon
className={cn("h-5 w-5", action.iconClasses)}
/>
))}
{action.label && (
<span className="text-sm px-1 pt-0.5">
{action.label}
</span>
)}
</button>
);
})}
</div>
</div>
</header>

2
src/components/UI/Generator.tsx

@ -22,7 +22,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
devicePSKBitCount?: number;
value: string;
id: string;
variant: "default" | "invalid";
variant: "default" | "invalid" | "dirty";
actionButtons: ActionButton[];
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void;

23
src/components/UI/Input.tsx

@ -6,14 +6,17 @@ import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
import { useTranslation } from "react-i18next";
const cnInvalidBase = "border-2 border-red-500 dark:border-red-500";
const cnDirtyBase = "border-2 border-sky-500 dark:border-sky-500";
const inputVariants = cva(
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:bg-transparet dark:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-600",
{
variants: {
variant: {
default: "border-slate-300 dark:border-slate-500",
invalid:
"border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-500",
invalid: `${cnInvalidBase} focus:ring-red-500 dark:focus:ring-red-500`,
dirty: `${cnDirtyBase} focus:ring-sky-500 dark:focus:ring-sky-500`,
},
},
defaultVariants: {
@ -49,6 +52,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className,
containerClassName,
variant,
disabled,
type = "text",
prefix,
suffix,
@ -137,6 +141,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className,
);
const extrasClassName = cn([
variant === "invalid" && `${cnInvalidBase} border-l-0`,
variant === "dirty" && `${cnDirtyBase} border-l-0`,
]);
return (
<div
className={cn("relative flex w-full items-stretch", containerClassName)}
@ -153,6 +162,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref={ref}
value={value}
onChange={onChange}
disabled={disabled}
{...props}
/>
@ -161,6 +171,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<span
className={cn(
"inline-flex items-center border border-l-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300",
extrasClassName,
!hasActions && "rounded-r-md",
)}
>
@ -171,7 +182,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
{hasActions && (
<div
className={cn(
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-700",
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-500",
extrasClassName,
disabled &&
"border-slate-200 dark:border-slate-700 divide-slate-200",
!hasSuffix && "rounded-r-md",
"bg-white dark:bg-slate-800",
)}
@ -181,7 +195,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
key={action.id}
type="button"
className={cn(
"inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 hover:rounded-md dark:hover:rounded-md",
"inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 last:hover:rounded-r-md last:dark:hover:rounded-r-md",
disabled && "text-slate-300 dark:text-slate-600",
action.id === "copy-value" && isCopied &&
"text-green-600 dark:text-green-500",
)}

7
src/components/UI/Sidebar/SidebarButton.tsx

@ -13,6 +13,7 @@ export interface SidebarButtonProps {
onClick?: () => void;
disabled?: boolean;
preventCollapse?: boolean;
isDirty?: boolean;
}
export const SidebarButton = ({
@ -24,6 +25,7 @@ export const SidebarButton = ({
onClick,
disabled = false,
preventCollapse = false,
isDirty,
}: SidebarButtonProps) => {
const { isCollapsed: isSidebarCollapsed } = useSidebar();
const isButtonCollapsed = isSidebarCollapsed && !preventCollapse;
@ -64,13 +66,14 @@ export const SidebarButton = ({
{label}
</span>
{!isButtonCollapsed && !active && count && count > 0 && (
{!isButtonCollapsed && ((!active && count && count > 0) || isDirty) && (
<div
className={cn(
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600",
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5",
"flex-shrink-0",
"transition-opacity duration-300 ease-in-out",
isButtonCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
isDirty ? "bg-sky-500" : "bg-red-600",
)}
>
{count}

7
src/core/stores/deviceStore.mock.ts

@ -44,12 +44,19 @@ export const mockDeviceStore: Device = {
unsafeRoles: false,
refreshKeys: false,
deleteMessages: false,
managedMode: false,
},
setStatus: vi.fn(),
setConfig: vi.fn(),
setModuleConfig: vi.fn(),
setWorkingConfig: vi.fn(),
setWorkingModuleConfig: vi.fn(),
getWorkingConfig: vi.fn(),
getWorkingModuleConfig: vi.fn(),
removeWorkingConfig: vi.fn(),
removeWorkingModuleConfig: vi.fn(),
getEffectiveConfig: vi.fn(),
getEffectiveModuleConfig: vi.fn(),
setHardware: vi.fn(),
setActiveNode: vi.fn(),
setPendingSettingsChanges: vi.fn(),

132
src/core/stores/deviceStore.ts

@ -18,6 +18,14 @@ type NodeError = {
node: number;
error: string;
};
export type ValidConfigType = Exclude<
Protobuf.Config.Config["payloadVariant"]["case"],
"deviceUi" | "sessionkey" | undefined
>;
export type ValidModuleConfigType = Exclude<
Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"],
undefined
>;
export interface Device {
id: number;
@ -54,6 +62,7 @@ export interface Device {
unsafeRoles: boolean;
refreshKeys: boolean;
deleteMessages: boolean;
managedMode: boolean;
};
setStatus: (status: Types.DeviceStatusEnum) => void;
@ -61,6 +70,26 @@ export interface Device {
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setWorkingConfig: (config: Protobuf.Config.Config) => void;
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
getWorkingConfig: (
payloadVariant: ValidConfigType,
) =>
| Protobuf.LocalOnly.LocalConfig[Exclude<ValidConfigType, undefined>]
| undefined;
getWorkingModuleConfig: (
payloadVariant: ValidModuleConfigType,
) =>
| Protobuf.LocalOnly.LocalModuleConfig[
Exclude<ValidModuleConfigType, undefined>
]
| undefined;
removeWorkingConfig: (payloadVariant?: ValidConfigType) => void;
removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void;
getEffectiveConfig<K extends ValidConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined;
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void;
@ -142,6 +171,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
refreshKeys: false,
rebootOTA: false,
deleteMessages: false,
managedMode: false,
},
pendingSettingsChanges: false,
messageDraft: "",
@ -277,6 +307,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
const index = device.workingConfig.findIndex(
(wc) => wc.payloadVariant.case === config.payloadVariant.case,
);
if (index !== -1) {
device.workingConfig[index] = config;
} else {
@ -297,6 +328,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
wmc.payloadVariant.case ===
moduleConfig.payloadVariant.case,
);
if (index !== -1) {
device.workingModuleConfig[index] = moduleConfig;
} else {
@ -305,6 +337,106 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}),
);
},
getWorkingConfig: (payloadVariant: ValidConfigType) => {
const device = get().devices.get(id);
if (!device) return;
const workingConfig = device.workingConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
);
if (
workingConfig?.payloadVariant.case === "deviceUi" ||
workingConfig?.payloadVariant.case === "sessionkey"
) return;
return workingConfig?.payloadVariant.value;
},
getWorkingModuleConfig: (payloadVariant: ValidModuleConfigType) => {
const device = get().devices.get(id);
if (!device) return;
return device.workingModuleConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value;
},
removeWorkingConfig: (payloadVariant?: ValidConfigType) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
if (!payloadVariant) {
device.workingConfig = [];
return;
}
const index = device.workingConfig.findIndex(
(wc: Protobuf.Config.Config) =>
wc.payloadVariant.case === payloadVariant,
);
if (index !== -1) {
device.workingConfig.splice(index, 1);
}
}),
);
},
removeWorkingModuleConfig: (
payloadVariant?: ValidModuleConfigType,
) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
if (!payloadVariant) {
device.workingModuleConfig = [];
return;
}
const index = device.workingModuleConfig.findIndex(
(wc: Protobuf.ModuleConfig.ModuleConfig) =>
wc.payloadVariant.case === payloadVariant,
);
if (index !== -1) {
device.workingModuleConfig.splice(index, 1);
}
}),
);
},
getEffectiveConfig<K extends ValidConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined {
if (!payloadVariant) return;
const device = get().devices.get(id);
if (!device) return;
return {
...device.config[payloadVariant],
...(device.workingConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value),
};
},
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined {
const device = get().devices.get(id);
if (!device) return;
return {
...device.moduleConfig[payloadVariant],
...(device.workingModuleConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value),
};
},
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
set(
produce<PrivateDeviceState>((draft) => {

60
src/core/utils/deepCompareConfig.test.ts

@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { deepCompareConfig } from "./deepCompareConfig.ts";
describe("deepCompareConfig", () => {
it("returns true for identical primitives", () => {
expect(deepCompareConfig(5, 5)).toBe(true);
expect(deepCompareConfig("foo", "foo")).toBe(true);
expect(deepCompareConfig(true, true)).toBe(true);
});
it("returns false for different primitives", () => {
expect(deepCompareConfig(5, 6)).toBe(false);
expect(deepCompareConfig("foo", "bar")).toBe(false);
expect(deepCompareConfig(true, false)).toBe(false);
});
it("handles nulls correctly", () => {
expect(deepCompareConfig(null, null)).toBe(true);
expect(deepCompareConfig(null, undefined)).toBe(false);
expect(deepCompareConfig(null, {})).toBe(false);
});
it("allows undefined in working when allowUndefined is true", () => {
expect(deepCompareConfig({ a: 1 }, { a: undefined }, true)).toBe(true);
expect(deepCompareConfig([1, 2, 3], [1, undefined, 3], true)).toBe(true);
});
it("rejects undefined in working when allowUndefined is false", () => {
expect(deepCompareConfig({ a: 1 }, { a: undefined }, false)).toBe(false);
});
it("compares arrays deeply", () => {
expect(deepCompareConfig([1, [2, 3]], [1, [2, 3]])).toBe(true);
expect(deepCompareConfig([1, [2, 3]], [1, [2, 4]])).toBe(false);
});
it("compares objects deeply", () => {
const existing = { x: 10, y: { z: 20 } };
const workingEqual = { x: 10, y: { z: 20 } };
const workingDiff = { x: 10, y: { z: 21 } };
expect(deepCompareConfig(existing, workingEqual)).toBe(true);
expect(deepCompareConfig(existing, workingDiff)).toBe(false);
});
it("ignores $typeName key in existing", () => {
const existing = { $typeName: "Test", a: 1 };
const working = { a: 1 };
expect(deepCompareConfig(existing, working)).toBe(true);
});
it("fails when working has extra keys", () => {
expect(deepCompareConfig({ a: 1 }, { a: 1, b: 2 })).toBe(false);
});
it("allows working arrays to be shorter if allowUndefined is true", () => {
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], true)).toBe(true);
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], false)).toBe(false);
});
});

58
src/core/utils/deepCompareConfig.ts

@ -0,0 +1,58 @@
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function deepCompareConfig(
a: unknown,
b: unknown,
allowUndefined = false,
): boolean {
if (a === b) {
return true;
}
// If allowUndefined is true, and one is undefined, they are considered equal. // This check is placed early to simplify subsequent logic.
if (allowUndefined && (a === undefined || b === undefined)) {
return true;
}
if (typeof a !== typeof b || a === null || b === null) {
return false;
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length && !allowUndefined) {
return false;
}
const longestLength = Math.max(a.length, b.length);
for (let i = 0; i < longestLength; i++) {
if (!deepCompareConfig(a[i], b[i], allowUndefined)) {
return false;
}
}
return true;
}
if (isObject(a) && isObject(b)) {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
const allKeys = new Set([...aKeys, ...bKeys]);
for (const key of allKeys) {
if (key === "$typeName") {
continue;
}
const aValue = a[key];
const bValue = b[key];
if (!deepCompareConfig(aValue, bValue, allowUndefined)) {
return false;
}
}
return true;
}
return false;
}

1
src/i18n/locales/en/common.json

@ -72,6 +72,7 @@
"unset": "UNSET",
"fallbackName": "Meshtastic {{last4}}",
"formValidation": {
"unsavedChanges": "Unsaved changes",
"tooBig": {
"string": "Too long, expected less than or equal to {{maximum}} characters.",
"number": "Too big, expected a number smaller than or equal to {{maximum}}.",

5
src/i18n/locales/en/dialog.json

@ -162,5 +162,10 @@
"choosingRightDeviceRole": "Choosing The Right Device Role",
"deviceRoleDocumentation": "Device Role Documentation",
"title": "Are you sure?"
},
"managedMode": {
"confirmUnderstanding": "Yes, I know what I'm doing",
"title": "Are you sure?",
"description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can <bold>only</bold> be changed through Remote Admin messages. This setting is not required for remote node administration."
}
}

54
src/pages/Config/DeviceConfig.tsx

@ -12,45 +12,77 @@ import {
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { useTranslation } from "react-i18next";
import { useDevice, type ValidConfigType } from "@core/stores/deviceStore.ts";
import { useMemo } from "react";
import { type ComponentType, Suspense } from "react";
import type { UseFormReturn } from "react-hook-form";
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
export const DeviceConfig = () => {
interface ConfigProps {
// We can get rid of this exception if we import every config schema and pass the union type
// deno-lint-ignore no-explicit-any
onFormInit: (methods: UseFormReturn<any>) => void;
}
type TabItem = {
case: ValidConfigType;
label: string;
element: ComponentType<ConfigProps>;
count?: number;
};
export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
const { getWorkingConfig } = useDevice();
const { t } = useTranslation("deviceConfig");
const tabs = [
const tabs: TabItem[] = [
{
case: "device",
label: t("page.tabDevice"),
element: Device,
count: 0,
},
{
case: "position",
label: t("page.tabPosition"),
element: Position,
},
{
case: "power",
label: t("page.tabPower"),
element: Power,
},
{
case: "network",
label: t("page.tabNetwork"),
element: Network,
},
{
case: "display",
label: t("page.tabDisplay"),
element: Display,
},
{
case: "lora",
label: t("page.tabLora"),
element: LoRa,
},
{
case: "bluetooth",
label: t("page.tabBluetooth"),
element: Bluetooth,
},
{
case: "security",
label: t("page.tabSecurity"),
element: Security,
},
];
] as const;
const flags = useMemo(
() => new Map(tabs.map((tab) => [tab.case, getWorkingConfig(tab.case)])),
[tabs, getWorkingConfig],
);
return (
<Tabs defaultValue={t("page.tabDevice")}>
@ -59,15 +91,27 @@ export const DeviceConfig = () => {
<TabsTrigger
key={tab.label}
value={tab.label}
className="dark:text-white"
className="dark:text-white relative"
>
{tab.label}
{flags.get(tab.case) && (
<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>
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
</span>
</span>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<tab.element />
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<ConfigSuspender configCase={tab.case}>
<tab.element onFormInit={onFormInit} />
</ConfigSuspender>
</Suspense>
</TabsContent>
))}
</Tabs>

66
src/pages/Config/ModuleConfig.tsx

@ -16,60 +16,96 @@ import {
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { useTranslation } from "react-i18next";
import {
useDevice,
type ValidModuleConfigType,
} from "@core/stores/deviceStore.ts";
import { useMemo } from "react";
import { type ComponentType, Suspense } from "react";
import type { UseFormReturn } from "react-hook-form";
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
interface ConfigProps {
// We can get rid of this exception if we import every config schema and pass the union type
// deno-lint-ignore no-explicit-any
onFormInit: (methods: UseFormReturn<any>) => void;
}
type TabItem = {
case: ValidModuleConfigType;
label: string;
element: ComponentType<ConfigProps>;
count?: number;
};
export const ModuleConfig = () => {
export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
const { getWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const tabs = [
const tabs: TabItem[] = [
{
case: "mqtt",
label: t("page.tabMqtt"),
element: MQTT,
},
{
case: "serial",
label: t("page.tabSerial"),
element: Serial,
},
{
case: "externalNotification",
label: t("page.tabExternalNotification"),
element: ExternalNotification,
},
{
case: "storeForward",
label: t("page.tabStoreAndForward"),
element: StoreForward,
},
{
case: "rangeTest",
label: t("page.tabRangeTest"),
element: RangeTest,
},
{
case: "telemetry",
label: t("page.tabTelemetry"),
element: Telemetry,
},
{
case: "cannedMessage",
label: t("page.tabCannedMessage"),
element: CannedMessage,
},
{
case: "audio",
label: t("page.tabAudio"),
element: Audio,
},
{
case: "neighborInfo",
label: t("page.tabNeighborInfo"),
element: NeighborInfo,
},
{
case: "ambientLighting",
label: t("page.tabAmbientLighting"),
element: AmbientLighting,
},
{
case: "detectionSensor",
label: t("page.tabDetectionSensor"),
element: DetectionSensor,
},
{
label: t("page.tabPaxcounter"),
element: Paxcounter,
},
];
{ case: "paxcounter", label: t("page.tabPaxcounter"), element: Paxcounter },
] as const;
const flags = useMemo(
() =>
new Map(tabs.map((tab) => [tab.case, getWorkingModuleConfig(tab.case)])),
[tabs, getWorkingModuleConfig],
);
return (
<Tabs defaultValue={t("page.tabMqtt")}>
@ -78,15 +114,27 @@ export const ModuleConfig = () => {
<TabsTrigger
key={tab.label}
value={tab.label}
className="dark:text-white"
className="dark:text-white relative"
>
{tab.label}
{flags.get(tab.case) && (
<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>
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
</span>
</span>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<tab.element />
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<ConfigSuspender moduleConfigCase={tab.case}>
<tab.element onFormInit={onFormInit} />
</ConfigSuspender>
</Suspense>
</TabsContent>
))}
</Tabs>

187
src/pages/Config/index.tsx

@ -1,27 +1,49 @@
import { useAppStore } from "../../core/stores/appStore.ts";
import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "../../components/UI/Sidebar/SidebarButton.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
import {
BoxesIcon,
RefreshCwIcon,
SaveIcon,
SaveOff,
SettingsIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@core/utils/cn.ts";
import type { UseFormReturn } from "react-hook-form";
const ConfigPage = () => {
const { workingConfig, workingModuleConfig, connection } = useDevice();
const {
workingConfig,
workingModuleConfig,
connection,
removeWorkingConfig,
removeWorkingModuleConfig,
setConfig,
setModuleConfig,
} = useDevice();
const { hasErrors } = useAppStore();
const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module"
>("device");
const [isSaving, setIsSaving] = useState(false);
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
const { toast } = useToast();
const isError = hasErrors();
const { t } = useTranslation("deviceConfig");
const onFormInit = (methods: UseFormReturn) => {
setFormMethods(methods);
};
const handleSave = async () => {
if (hasErrors()) {
return toast({
@ -31,36 +53,49 @@ const ConfigPage = () => {
}
setIsSaving(true);
try {
if (activeConfigSection === "device") {
await Promise.all(
workingConfig.map((config) =>
connection?.setConfig(config).then(() =>
toast({
title: t("toast.saveSuccess.title"),
description: t("toast.saveSuccess.description", {
case: config.payloadVariant.case,
}),
})
)
),
);
} else {
await Promise.all(
workingModuleConfig.map((moduleConfig) =>
connection?.setModuleConfig(moduleConfig).then(() =>
toast({
title: t("toast.saveSuccess.title"),
description: t("toast.saveSuccess.description", {
case: moduleConfig.payloadVariant.case,
}),
})
)
),
await Promise.all(
workingConfig.map((newConfig) =>
connection?.setConfig(newConfig).then(() => {
toast({
title: t("toast.saveSuccess.title"),
description: t("toast.saveSuccess.description", {
case: newConfig.payloadVariant.case,
}),
});
})
),
);
await Promise.all(
workingModuleConfig.map((newModuleConfig) =>
connection?.setModuleConfig(newModuleConfig).then(() =>
toast({
title: t("toast.saveSuccess.title"),
description: t("toast.saveSuccess.description", {
case: newModuleConfig.payloadVariant.case,
}),
})
)
),
);
await connection?.commitEditSettings().then(() => {
if (formMethods) {
formMethods.reset({}, {
keepValues: true,
});
}
workingConfig.map((newConfig) => setConfig(newConfig));
workingModuleConfig.map((newModuleConfig) =>
setModuleConfig(newModuleConfig)
);
setIsSaving(false);
}
await connection?.commitEditSettings();
removeWorkingConfig();
removeWorkingModuleConfig();
});
} catch (_error) {
toast({
title: t("toast.configSaveError.title"),
@ -71,6 +106,15 @@ const ConfigPage = () => {
}
};
const handleReset = () => {
if (formMethods) {
formMethods.reset();
}
removeWorkingConfig();
removeWorkingModuleConfig();
};
const leftSidebar = useMemo(
() => (
<Sidebar>
@ -83,19 +127,79 @@ const ConfigPage = () => {
active={activeConfigSection === "device"}
onClick={() => setActiveConfigSection("device")}
Icon={SettingsIcon}
isDirty={workingConfig.length > 0}
count={workingConfig.length}
/>
<SidebarButton
label={t("navigation.moduleConfig")}
active={activeConfigSection === "module"}
onClick={() => setActiveConfigSection("module")}
Icon={BoxesIcon}
isDirty={workingModuleConfig.length > 0}
count={workingModuleConfig.length}
/>
</SidebarSection>
</Sidebar>
),
[activeConfigSection],
[activeConfigSection, workingConfig, workingModuleConfig],
);
const buttonOpacity = useMemo(
() => (formMethods?.formState.isDirty &&
Object.keys(formMethods?.formState.dirtyFields ?? {}).length > 0 ||
workingConfig.length > 0 || workingModuleConfig.length > 0
? "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(() => [
{
key: "unsavedChanges",
label: t("common:formValidation.unsavedChanges"),
onClick: () => {},
className: cn([
"bg-blue-500 hover:bg-blue-500 text-white hover:text-white",
buttonOpacity,
"transition-opacity",
]),
},
{
key: "reset",
icon: RefreshCwIcon,
label: t("common:button.reset"),
onClick: handleReset,
className: cn([buttonOpacity, "transition-opacity"]),
},
{
key: "save",
icon: !isValid ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled: isSaving ||
!isValid ||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
iconClasses: !isValid ? "text-red-400 cursor-not-allowed" : "",
onClick: handleSave,
label: t("common:button.save"),
},
], [
activeConfigSection,
isSaving,
isValid,
buttonOpacity,
workingConfig,
workingModuleConfig,
]);
return (
<>
<PageLayout
@ -104,18 +208,11 @@ const ConfigPage = () => {
label={activeConfigSection === "device"
? t("navigation.radioConfig")
: t("navigation.moduleConfig")}
actions={[
{
key: "save",
icon: isError ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled: isSaving,
iconClasses: isError ? "text-red-400 cursor-not-allowed" : "",
onClick: handleSave,
},
]}
actions={actions}
>
{activeConfigSection === "device" ? <DeviceConfig /> : <ModuleConfig />}
{activeConfigSection === "device"
? <DeviceConfig onFormInit={onFormInit} />
: <ModuleConfig onFormInit={onFormInit} />}
</PageLayout>
</>
);

2
src/validation/config/network.ts

@ -19,7 +19,7 @@ export const NetworkValidationSchema = z.object({
wifiEnabled: z.boolean(),
wifiSsid: z.string().max(33),
wifiPsk: z.string().max(64),
ntpServer: z.string().min(2).max(33),
ntpServer: z.string().min(0).max(33),
ethEnabled: z.boolean(),
addressMode: AddressModeEnum,
ipv4Config: NetworkValidationIpV4ConfigSchema,

4
src/validation/config/security.test.ts

@ -53,7 +53,7 @@ describe("RawSecuritySchema", () => {
if (!result.success) {
expect(
result.error.issues.some((i) =>
i.message === "formValidation.adminKeyRequiredWhenManaged"
i.message === "formValidation.required.managed"
),
).toBe(true);
}
@ -103,7 +103,7 @@ describe("ParsedSecuritySchema", () => {
if (!result.success) {
expect(
result.error.issues.some((i) =>
i.message === "formValidation.adminKeyRequiredWhenManaged"
i.message === "formValidation.required.managed"
),
).toBe(true);
}

2
src/validation/config/security.ts

@ -7,7 +7,7 @@ const {
isValidKey,
} = makePskHelpers([32]); // 256-bit
const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged";
const isManagedRequiredMsg = "formValidation.required.managed";
function makeSecuritySchema<KeyT>(
keyMaker: (optional: boolean) => ZodType<KeyT>,

4
src/validation/moduleConfig/mqtt.ts

@ -1,8 +1,8 @@
import { z } from "zod/v4";
export const MqttValidationMapReportSettingsSchema = z.object({
publishIntervalSecs: z.number().optional(),
positionPrecision: z.number().optional(),
publishIntervalSecs: z.coerce.number().int(),
positionPrecision: z.coerce.number().int(),
});
export const MqttValidationSchema = z.object({

Loading…
Cancel
Save