diff --git a/src/components/Form/DynamicForm.tsx b/src/components/Form/DynamicForm.tsx index e7f91f51..fe55a0e0 100644 --- a/src/components/Form/DynamicForm.tsx +++ b/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"; @@ -47,10 +48,13 @@ export interface GenericFormElementProps { } export interface DynamicFormProps { + propMethods?: UseFormReturn; onSubmit: SubmitHandler; + onFormInit?: DynamicFormFormInit; submitType?: "onChange" | "onSubmit"; hasSubmitButton?: boolean; defaultValues?: DefaultValues; + values?: T; fieldGroups: { label: string; description: string; @@ -63,11 +67,18 @@ export interface DynamicFormProps { formId?: string; } +export type DynamicFormFormInit = ( + methods: UseFormReturn, +) => void; + export function DynamicForm({ + propMethods, onSubmit, + onFormInit, submitType = "onChange", hasSubmitButton, defaultValues, + values, fieldGroups, validationSchema, formId, @@ -78,18 +89,29 @@ export function DynamicForm({ removeError, } = useAppStore(); - const methods = useForm< - T - >({ - mode: "onChange", - defaultValues: defaultValues, - resolver: validationSchema - ? createZodResolver(validationSchema) - : undefined, - shouldFocusError: false, - }); + 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 } = methods; + useEffect(() => { + if (!propMethods) { + onFormInit?.(methods); + } + }, [onFormInit, propMethods, methods]); + useEffect(() => { const errorKeys = Object.keys(formState.errors); if (formId) { @@ -108,7 +130,10 @@ export function DynamicForm({ } } }, [formState.errors]); - + console.debug( + `DynamicForm: ${formId} - Form state:`, + formState.isDirty, + ); const isDisabled = ( disabledBy?: DisabledBy[], disabled?: boolean, @@ -155,20 +180,15 @@ export function DynamicForm({ 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, + }), + ) + : ""} > ({ fieldLength?.currentValueLength || 0, ); - const { field: controllerField } = useController({ - name: field.name, - control, - }); + const { field: controllerField, fieldState: { invalid, isDirty } } = + useController({ + name: field.name, + control, + }); const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; @@ -78,6 +79,7 @@ export function GenericInput({ className={field.properties?.className} {...restProperties} disabled={disabled} + variant={invalid ? "invalid" : isDirty ? "dirty" : "default"} /> {fieldLength?.showCharacterCount && fieldLength?.max && ( diff --git a/src/components/Form/FormPasswordGenerator.tsx b/src/components/Form/FormPasswordGenerator.tsx index ea5cf51a..bc8e1136 100644 --- a/src/components/Form/FormPasswordGenerator.tsx +++ b/src/components/Form/FormPasswordGenerator.tsx @@ -42,7 +42,12 @@ export function PasswordGenerator({ ( + render={( + { + field: { value, onChange, ...rest }, + fieldState: { invalid, isDirty }, + }, + ) => ( ({ }} 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} diff --git a/src/components/PageComponents/Config/Bluetooth.tsx b/src/components/PageComponents/Config/Bluetooth.tsx index efd1111c..0f06a49e 100644 --- a/src/components/PageComponents/Config/Bluetooth.tsx +++ b/src/components/PageComponents/Config/Bluetooth.tsx @@ -3,13 +3,18 @@ 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"; - -export const Bluetooth = () => { - const { config, setWorkingConfig } = useDevice(); +interface BluetoothConfigProps { + onFormInit: DynamicFormFormInit; +} +export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => { + const { config, setWorkingConfig, getEffectiveConfig } = useDevice(); const { t } = useTranslation("deviceConfig"); const onSubmit = (data: BluetoothValidation) => { @@ -26,9 +31,11 @@ export const Bluetooth = () => { return ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={BluetoothValidationSchema} formId="Config_BluetoothConfig" defaultValues={config.bluetooth} + values={getEffectiveConfig("bluetooth")} fieldGroups={[ { label: t("bluetooth.title"), diff --git a/src/components/PageComponents/Config/Device/index.tsx b/src/components/PageComponents/Config/Device/index.tsx index 68e4b8ad..7b71e6c4 100644 --- a/src/components/PageComponents/Config/Device/index.tsx +++ b/src/components/PageComponents/Config/Device/index.tsx @@ -3,14 +3,20 @@ 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"; -export const Device = () => { - const { config, setWorkingConfig } = useDevice(); +interface DeviceConfigProps { + onFormInit: DynamicFormFormInit; +} +export const Device = ({ onFormInit }: DeviceConfigProps) => { + const { config, setWorkingConfig, getEffectiveConfig } = useDevice(); const { t } = useTranslation("deviceConfig"); const { validateRoleSelection } = useUnsafeRolesDialog(); @@ -24,12 +30,15 @@ export const Device = () => { }), ); }; + return ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={DeviceValidationSchema} formId="Config_DeviceConfig" defaultValues={config.device} + values={getEffectiveConfig("device")} fieldGroups={[ { label: t("device.title"), @@ -97,7 +106,8 @@ export const Device = () => { properties: { fieldLength: { max: 64, - currentValueLength: config.device?.tzdef?.length, + currentValueLength: getEffectiveConfig("device")?.tzdef + ?.length, showCharacterCount: true, }, }, diff --git a/src/components/PageComponents/Config/Display.tsx b/src/components/PageComponents/Config/Display.tsx index 0cda0f33..43e0de4c 100644 --- a/src/components/PageComponents/Config/Display.tsx +++ b/src/components/PageComponents/Config/Display.tsx @@ -3,13 +3,19 @@ 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"; -export const Display = () => { - const { config, setWorkingConfig } = useDevice(); +interface DisplayConfigProps { + onFormInit: DynamicFormFormInit; +} +export const Display = ({ onFormInit }: DisplayConfigProps) => { + const { config, setWorkingConfig, getEffectiveConfig } = useDevice(); const { t } = useTranslation("deviceConfig"); const onSubmit = (data: DisplayValidation) => { @@ -26,9 +32,11 @@ export const Display = () => { return ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={DisplayValidationSchema} formId="Config_DisplayConfig" defaultValues={config.display} + values={getEffectiveConfig("display")} fieldGroups={[ { label: t("display.title"), diff --git a/src/components/PageComponents/Config/LoRa.tsx b/src/components/PageComponents/Config/LoRa.tsx index cf3abdd5..9b961ffd 100644 --- a/src/components/PageComponents/Config/LoRa.tsx +++ b/src/components/PageComponents/Config/LoRa.tsx @@ -3,13 +3,19 @@ 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"; -export const LoRa = () => { - const { config, setWorkingConfig } = useDevice(); +interface LoRaConfigProps { + onFormInit: DynamicFormFormInit; +} +export const LoRa = ({ onFormInit }: LoRaConfigProps) => { + const { config, setWorkingConfig, getEffectiveConfig } = useDevice(); const { t } = useTranslation("deviceConfig"); const onSubmit = (data: LoRaValidation) => { @@ -26,9 +32,11 @@ export const LoRa = () => { return ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={LoRaValidationSchema} formId="Config_LoRaConfig" defaultValues={config.lora} + values={getEffectiveConfig("lora")} fieldGroups={[ { label: t("lora.title"), diff --git a/src/components/PageComponents/Config/Network/index.tsx b/src/components/PageComponents/Config/Network/index.tsx index 3d944668..771f1f2b 100644 --- a/src/components/PageComponents/Config/Network/index.tsx +++ b/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, @@ -12,9 +15,15 @@ import { import { Protobuf } from "@meshtastic/core"; import { useTranslation } from "react-i18next"; -export const Network = () => { - const { config, setWorkingConfig } = useDevice(); +interface NetworkConfigProps { + onFormInit: DynamicFormFormInit; +} +export const Network = ({ onFormInit }: NetworkConfigProps) => { + const { config, setWorkingConfig, getEffectiveConfig } = useDevice(); const { t } = useTranslation("deviceConfig"); + + const networkConfig = getEffectiveConfig("network"); + const onSubmit = (data: NetworkValidation) => { setWorkingConfig( create(Protobuf.Config.ConfigSchema, { @@ -36,10 +45,10 @@ export const Network = () => { }), ); }; - return ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={NetworkValidationSchema} formId="Config_NetworkConfig" defaultValues={{ @@ -57,6 +66,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"), diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index b306117c..c98e2680 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -7,16 +7,22 @@ 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"; -export const Position = () => { - const { config, setWorkingConfig } = useDevice(); +interface PositionConfigProps { + onFormInit: DynamicFormFormInit; +} +export const Position = ({ onFormInit }: PositionConfigProps) => { + const { setWorkingConfig, config, getEffectiveConfig } = useDevice(); const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags( - config?.position?.positionFlags ?? 0, + getEffectiveConfig("position")?.positionFlags ?? 0, ); const { t } = useTranslation("deviceConfig"); @@ -44,9 +50,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"), diff --git a/src/components/PageComponents/Config/Power.tsx b/src/components/PageComponents/Config/Power.tsx index b81e1aaa..5d5fa92a 100644 --- a/src/components/PageComponents/Config/Power.tsx +++ b/src/components/PageComponents/Config/Power.tsx @@ -3,13 +3,19 @@ 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"; -export const Power = () => { - const { config, setWorkingConfig } = useDevice(); +interface PowerConfigProps { + onFormInit: DynamicFormFormInit; +} +export const Power = ({ onFormInit }: PowerConfigProps) => { + const { setWorkingConfig, config, getEffectiveConfig } = useDevice(); const { t } = useTranslation("deviceConfig"); const onSubmit = (data: PowerValidation) => { @@ -26,9 +32,11 @@ export const Power = () => { return ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={PowerValidationSchema} formId="Config_PowerConfig" defaultValues={config.power} + values={getEffectiveConfig("power")} fieldGroups={[ { label: t("power.powerConfigSettings.label"), diff --git a/src/components/PageComponents/Config/Security/Security.tsx b/src/components/PageComponents/Config/Security/Security.tsx index 5b3f6e77..3b475968 100644 --- a/src/components/PageComponents/Config/Security/Security.tsx +++ b/src/components/PageComponents/Config/Security/Security.tsx @@ -1,5 +1,8 @@ import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"; -import { DynamicForm } from "@components/Form/DynamicForm.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 +10,75 @@ 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"; +interface SecurityConfigProps { + onFormInit: DynamicFormFormInit; +} +export const Security = ({ onFormInit }: SecurityConfigProps) => { + const { + setWorkingConfig, + setDialogOpen, + getEffectiveConfig, + } = useDevice(); -type KeyState = { - publicKey: string; - privateKey: string; - privateKeyDialogOpen: boolean; -}; - -export const Security = () => { - const { config, setWorkingConfig, setDialogOpen } = useDevice(); const { removeError } = useAppStore(); const { t } = useTranslation("deviceConfig"); - const [keyState, setKeyState] = useState(() => ({ - 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({ + mode: "onChange", + defaultValues: defaultValues as DefaultValues, + resolver: createZodResolver(RawSecuritySchema), + shouldFocusError: false, + resetOptions: { keepDefaultValues: true }, + }); + const { setValue, formState } = formMethods; + + useEffect(() => { + onFormInit?.(formMethods); + }, [onFormInit, formMethods]); + + const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState( + 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) ?? ""), @@ -54,18 +95,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 +106,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 +128,9 @@ export const Security = () => { return ( <> + 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 +151,7 @@ export const Security = () => { actionButtons: [ { text: t("button.generate"), - onClick: () => - setKeyState((prev) => ({ - ...prev, - privateKeyDialogOpen: true, - })), + onClick: () => setPrivateKeyDialogOpen(true), variant: "success", }, { @@ -160,8 +163,6 @@ export const Security = () => { properties: { showCopyButton: true, showPasswordToggle: true, - - value: keyState.privateKey, }, }, { @@ -172,7 +173,6 @@ export const Security = () => { description: t("security.publicKey.description"), properties: { showCopyButton: true, - value: keyState.publicKey, }, }, ], @@ -275,12 +275,8 @@ export const Security = () => { title: t("pkiRegenerate.title"), description: t("pkiRegenerate.description"), }} - open={keyState.privateKeyDialogOpen} - onOpenChange={() => - setKeyState((prev) => ({ - ...prev, - privateKeyDialogOpen: false, - }))} + open={privateKeyDialogOpen} + onOpenChange={() => setPrivateKeyDialogOpen(true)} onSubmit={pkiRegenerate} /> diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index a08a2afa..534ab71a 100644 --- a/src/components/PageLayout.tsx +++ b/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 = ({ {label} -
- {actions?.map((action) => ( - - ))} + onClick={action.onClick} + aria-label={action.ariaLabel || `Action ${action.key}`} + aria-disabled={action.disabled} + aria-busy={action.isLoading} + > + {action.icon && + (action.isLoading ? : ( + + ))} + {action.label && ( + + {action.label} + + )} + + ); + })}
diff --git a/src/components/UI/Generator.tsx b/src/components/UI/Generator.tsx index 2cb22b16..f23cf1e3 100644 --- a/src/components/UI/Generator.tsx +++ b/src/components/UI/Generator.tsx @@ -22,7 +22,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes { 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; diff --git a/src/components/UI/Input.tsx b/src/components/UI/Input.tsx index 81a072e7..a0cf627e 100644 --- a/src/components/UI/Input.tsx +++ b/src/components/UI/Input.tsx @@ -14,6 +14,8 @@ const inputVariants = cva( 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", + dirty: + "border-sky-500 dark:border-sky-500 focus:ring-sky-500 dark:focus:ring-sky-500", }, }, defaultVariants: { @@ -49,6 +51,7 @@ const Input = React.forwardRef( className, containerClassName, variant, + disabled, type = "text", prefix, suffix, @@ -153,6 +156,7 @@ const Input = React.forwardRef( ref={ref} value={value} onChange={onChange} + disabled={disabled} {...props} /> @@ -161,6 +165,8 @@ const Input = React.forwardRef( @@ -171,7 +177,11 @@ const Input = React.forwardRef( {hasActions && (
( 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", )} diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index d8b7b03d..fcc90560 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -1,6 +1,6 @@ import { create, toBinary } from "@bufbuild/protobuf"; import { MeshDevice, Protobuf, Types } from "@meshtastic/core"; -import { produce } from "immer"; +import { current, produce } from "immer"; import { createContext, useContext } from "react"; import { create as createStore } from "zustand"; @@ -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; @@ -61,6 +69,28 @@ 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] + | undefined; + getWorkingModuleConfig: ( + payloadVariant: ValidModuleConfigType, + ) => + | Protobuf.LocalOnly.LocalModuleConfig[ + Exclude + ] + | undefined; + removeWorkingConfig: (payloadVariant?: ValidConfigType) => void; + removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void; + getEffectiveConfig( + payloadVariant: K, + ): Protobuf.LocalOnly.LocalConfig[K] | undefined; + getEffectiveModuleConfig( + payloadVariant: K, + ): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined; + setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void; setActiveNode: (node: number) => void; setPendingSettingsChanges: (state: boolean) => void; @@ -277,7 +307,16 @@ export const useDeviceStore = createStore((set, get) => ({ const index = device.workingConfig.findIndex( (wc) => wc.payloadVariant.case === config.payloadVariant.case, ); - if (index !== -1) { + + if ( + // This doesn't quite work as expected, but it is a workaround for now. + JSON.stringify( + current(device.config[config.payloadVariant.case]), + ) === + JSON.stringify(config.payloadVariant.value) + ) { + device.workingConfig.splice(index, 1); + } else if (index !== -1) { device.workingConfig[index] = config; } else { device.workingConfig.push(config); @@ -297,7 +336,16 @@ export const useDeviceStore = createStore((set, get) => ({ wmc.payloadVariant.case === moduleConfig.payloadVariant.case, ); - if (index !== -1) { + if ( + JSON.stringify( + current( + device.moduleConfig[moduleConfig.payloadVariant.case], + ), + ) === + JSON.stringify(moduleConfig.payloadVariant.value) + ) { + device.workingModuleConfig.splice(index, 1); + } else if (index !== -1) { device.workingModuleConfig[index] = moduleConfig; } else { device.workingModuleConfig.push(moduleConfig); @@ -305,6 +353,99 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, + + getWorkingConfig: (payloadVariant: ValidConfigType) => { + const device = get().devices.get(id); + if (!device) return; + + return device.workingConfig.find( + (c) => c.payloadVariant.case === payloadVariant, + )?.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((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((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( + 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( + 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((draft) => { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index efb44b14..b59ceac6 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -71,6 +71,7 @@ "unset": "UNSET", "fallbackName": "Meshtastic {{last4}}", "formValidation": { + "unsavedChanges": "There are 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}}.", diff --git a/src/pages/Config/DeviceConfig.tsx b/src/pages/Config/DeviceConfig.tsx index 8a42d0c4..34d48d4d 100644 --- a/src/pages/Config/DeviceConfig.tsx +++ b/src/pages/Config/DeviceConfig.tsx @@ -13,44 +13,74 @@ import { TabsTrigger, } from "@components/UI/Tabs.tsx"; import { useTranslation } from "react-i18next"; +import { useDevice, type ValidConfigType } from "@core/stores/deviceStore.ts"; +import { useMemo } from "react"; +import type { ComponentType } from "react"; +import type { UseFormReturn } from "react-hook-form"; -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) => void; +} +type TabItem = { + case: ValidConfigType; + label: string; + element: ComponentType; + 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 ( @@ -59,15 +89,23 @@ export const DeviceConfig = () => { {tab.label} + {flags.get(tab.case) && ( + + + + + + + )} ))} {tabs.map((tab) => ( - + ))} diff --git a/src/pages/Config/index.tsx b/src/pages/Config/index.tsx index 337c2883..d4720224 100644 --- a/src/pages/Config/index.tsx +++ b/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, + } = useDevice(); const { hasErrors } = useAppStore(); + const [activeConfigSection, setActiveConfigSection] = useState< "device" | "module" >("device"); const [isSaving, setIsSaving] = useState(false); + const [formMethods, setFormMethods] = useState(null); + const { toast } = useToast(); const isError = hasErrors(); const { t } = useTranslation("deviceConfig"); + const onFormInit = (methods: UseFormReturn) => { + setFormMethods(methods); + }; + const handleSave = async () => { if (hasErrors()) { return toast({ @@ -29,6 +51,10 @@ const ConfigPage = () => { description: t("toast.validationError.description"), }); } + if ( + (activeConfigSection === "device" && workingConfig.length === 0) || + (activeConfigSection === "module" && workingModuleConfig.length === 0) + ) return; setIsSaving(true); try { @@ -60,7 +86,20 @@ const ConfigPage = () => { ); setIsSaving(false); } - await connection?.commitEditSettings(); + await connection?.commitEditSettings().then(() => { + if (activeConfigSection === "device") { + removeWorkingConfig(); + } else { + removeWorkingModuleConfig(); + } + if (formMethods) { + formMethods.reset({ + keepDefaultValues: true, + keepDirty: false, + keepDirtyValues: true, + }); + } + }); } catch (_error) { toast({ title: t("toast.configSaveError.title"), @@ -71,6 +110,18 @@ const ConfigPage = () => { } }; + const handleReset = () => { + if (formMethods) { + formMethods.reset(); + } + + if (activeConfigSection === "device") { + removeWorkingConfig(); + } else { + removeWorkingModuleConfig(); + } + }; + const leftSidebar = useMemo( () => ( @@ -96,6 +147,51 @@ const ConfigPage = () => { [activeConfigSection], ); + const buttonOpacity = useMemo( + () => (formMethods?.formState.isDirty || + (activeConfigSection === "device" && workingConfig.length > 0) || + (activeConfigSection === "module" && workingModuleConfig.length > 0) + ? "opacity-100" + : "opacity-0"), + [formMethods?.formState.isDirty, workingConfig, workingModuleConfig], + ); + + 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: isError ? SaveOff : SaveIcon, + isLoading: isSaving, + disabled: isSaving || + (activeConfigSection === "device" && workingConfig.length === 0) || + (activeConfigSection === "module" && workingModuleConfig.length === 0), + iconClasses: isError ? "text-red-400 cursor-not-allowed" : "", + onClick: handleSave, + label: t("common:button.save"), + }, + ], [ + activeConfigSection, + isError, + isSaving, + buttonOpacity, + ]); + return ( <> { 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" ? : } + {activeConfigSection === "device" + ? + : } ); diff --git a/src/validation/config/network.ts b/src/validation/config/network.ts index 26ba9c36..cdb486b0 100644 --- a/src/validation/config/network.ts +++ b/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, diff --git a/src/validation/config/security.ts b/src/validation/config/security.ts index 2053b085..4d7bd83a 100644 --- a/src/validation/config/security.ts +++ b/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( keyMaker: (optional: boolean) => ZodType,