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- <philon-@users.noreply.github.com>
This commit is contained in:
Jeremy Gallant
2025-06-18 10:04:17 -04:00
committed by GitHub
co-authored by philon-
parent 7adbe01723
commit 181c984b27
46 changed files with 1379 additions and 280 deletions
@@ -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"),
@@ -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;
}
@@ -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,
},
},
@@ -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"),
+18 -3
View File
@@ -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"),
@@ -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"),
@@ -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"),
+18 -3
View File
@@ -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"),
@@ -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);
}}
/>
</>
);
};