diff --git a/src/components/Dialog/ManagedModeDialog.tsx b/src/components/Dialog/ManagedModeDialog.tsx new file mode 100644 index 00000000..996139d4 --- /dev/null +++ b/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 ( + + + + + {t("managedMode.title")} + + , + }} + /> + + +
+ setConfirmState(!confirmState)} + name="confirmUnderstanding" + > +

+ {t("managedMode.confirmUnderstanding")} +

+
+
+ + + +
+
+ ); +}; diff --git a/src/components/Form/DynamicForm.tsx b/src/components/Form/DynamicForm.tsx index e7f91f51..3fe802d6 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"; @@ -44,13 +45,18 @@ export interface GenericFormElementProps { control: Control; disabled?: boolean; field: Y; + isDirty?: boolean; + invalid?: boolean; } 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 +69,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,17 +91,29 @@ export function DynamicForm({ 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({ 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, + }), + ) + : ""} > ); diff --git a/src/components/Form/DynamicFormField.tsx b/src/components/Form/DynamicFormField.tsx index 320db39f..34508613 100644 --- a/src/components/Form/DynamicFormField.tsx +++ b/src/components/Form/DynamicFormField.tsx @@ -31,19 +31,29 @@ export interface DynamicFormFieldProps { field: FieldProps; control: Control; disabled?: boolean; + isDirty?: boolean; + invalid?: boolean; } export function DynamicFormField({ field, control, disabled, + isDirty, + invalid, }: DynamicFormFieldProps) { switch (field.type) { case "text": case "password": case "number": return ( - + ); case "toggle": @@ -52,6 +62,8 @@ export function DynamicFormField({ field={field} control={control} disabled={disabled} + isDirty={isDirty} + invalid={invalid} /> ); case "select": @@ -60,6 +72,8 @@ export function DynamicFormField({ field={field} control={control} disabled={disabled} + isDirty={isDirty} + invalid={invalid} /> ); case "passwordGenerator": @@ -68,11 +82,19 @@ export function DynamicFormField({ field={field} control={control} disabled={disabled} + isDirty={isDirty} + invalid={invalid} /> ); case "multiSelect": return ( - + ); } } diff --git a/src/components/Form/FormInput.tsx b/src/components/Form/FormInput.tsx index c0af74ae..cd7ffea0 100644 --- a/src/components/Form/FormInput.tsx +++ b/src/components/Form/FormInput.tsx @@ -31,6 +31,8 @@ export function GenericInput({ control, disabled, field, + isDirty, + invalid, }: GenericFormElementProps>) { const { fieldLength, ...restProperties } = field.properties || {}; const [currentLength, setCurrentLength] = useState( @@ -78,6 +80,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/FormMultiSelect.tsx b/src/components/Form/FormMultiSelect.tsx index cea9a511..82354c88 100644 --- a/src/components/Form/FormMultiSelect.tsx +++ b/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 extends BaseFormBuilderProps { type: "multiSelect"; @@ -23,9 +24,11 @@ export interface MultiSelectFieldProps extends BaseFormBuilderProps { export function MultiSelectInput({ field, + isDirty, + invalid, }: GenericFormElementProps>) { 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({ ); return ( - + {optionsToRender.map((option) => { return ( ({ control, field, disabled, + isDirty, + invalid, }: GenericFormElementProps>) { const { isVisible } = usePasswordVisibilityToggle(); const { trigger } = useFormContext(); @@ -42,7 +44,11 @@ export function PasswordGenerator({ ( + render={( + { + field: { value, onChange, ...rest }, + }, + ) => ( ({ }} 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/Form/FormSelect.tsx b/src/components/Form/FormSelect.tsx index e665db8c..3b4cce04 100644 --- a/src/components/Form/FormSelect.tsx +++ b/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 extends BaseFormBuilderProps { type: "select"; @@ -38,6 +39,8 @@ export function SelectInput({ control, disabled, field, + isDirty, + invalid, }: GenericFormElementProps>) { const { field: { value, onChange, ref, onBlur, ...rest }, @@ -46,8 +49,13 @@ export function SelectInput({ control, }); - const { enumValue, formatEnumName, defaultValue, ...remainingProperties } = - field.properties; + const { + enumValue, + formatEnumName, + defaultValue, + className, + ...remainingProperties + } = field.properties; const valueToKeyMap: Record = {}; const optionsEnumValues: [string, number][] = []; @@ -82,6 +90,13 @@ export function SelectInput({ > extends BaseFormBuilderProps { type: "toggle"; @@ -14,12 +15,16 @@ export function ToggleInput({ control, disabled, field, + isDirty, + invalid, }: GenericFormElementProps>) { return ( ( + render={( + { field: { value, onChange, ...rest } }, + ) => ( { @@ -29,6 +34,15 @@ export function ToggleInput({ 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} /> )} diff --git a/src/components/PageComponents/Config/Bluetooth.tsx b/src/components/PageComponents/Config/Bluetooth.tsx index efd1111c..5d27c4a1 100644 --- a/src/components/PageComponents/Config/Bluetooth.tsx +++ b/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; +} +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 ( 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/ConfigSuspender.tsx b/src/components/PageComponents/Config/ConfigSuspender.tsx new file mode 100644 index 00000000..83eaa89d --- /dev/null +++ b/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; +} diff --git a/src/components/PageComponents/Config/Device/index.tsx b/src/components/PageComponents/Config/Device/index.tsx index 68e4b8ad..2d1689b2 100644 --- a/src/components/PageComponents/Config/Device/index.tsx +++ b/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; +} +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 ( 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, }, }, diff --git a/src/components/PageComponents/Config/Display.tsx b/src/components/PageComponents/Config/Display.tsx index 0cda0f33..42e0e546 100644 --- a/src/components/PageComponents/Config/Display.tsx +++ b/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; +} +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 ( 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..251f2fcb 100644 --- a/src/components/PageComponents/Config/LoRa.tsx +++ b/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; +} +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 ( 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..12e72362 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, @@ -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; +} +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 ( 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"), diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index b306117c..d6fa1a05 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/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; +} +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"), diff --git a/src/components/PageComponents/Config/Power.tsx b/src/components/PageComponents/Config/Power.tsx index b81e1aaa..c35c543d 100644 --- a/src/components/PageComponents/Config/Power.tsx +++ b/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; +} +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 ( 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..911227b5 100644 --- a/src/components/PageComponents/Config/Security/Security.tsx +++ b/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; +} +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(() => ({ - 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 [managedModeDialogOpen, setManagedModeDialogOpen] = 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) ?? ""), @@ -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 ( <> + 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} /> + + setManagedModeDialogOpen((prev) => !prev)} + onSubmit={() => { + setValue("isManaged", true); + setManagedModeDialogOpen(false); + }} + /> ); }; diff --git a/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx b/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx index 51f9cc5d..50da8ec6 100644 --- a/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx +++ b/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; +} -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 ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={AmbientLightingValidationSchema} formId="ModuleConfig_AmbientLightingConfig" defaultValues={moduleConfig.ambientLighting} + values={getEffectiveModuleConfig("ambientLighting")} fieldGroups={[ { label: t("ambientLighting.title"), diff --git a/src/components/PageComponents/ModuleConfig/Audio.tsx b/src/components/PageComponents/ModuleConfig/Audio.tsx index 4db01275..7a6ead8e 100644 --- a/src/components/PageComponents/ModuleConfig/Audio.tsx +++ b/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; +} -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 ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={AudioValidationSchema} formId="ModuleConfig_AudioConfig" defaultValues={moduleConfig.audio} + values={getEffectiveModuleConfig("audio")} fieldGroups={[ { label: t("audio.title"), diff --git a/src/components/PageComponents/ModuleConfig/CannedMessage.tsx b/src/components/PageComponents/ModuleConfig/CannedMessage.tsx index 80221028..b0e075e8 100644 --- a/src/components/PageComponents/ModuleConfig/CannedMessage.tsx +++ b/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; +} -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 ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={CannedMessageValidationSchema} formId="ModuleConfig_CannedMessageConfig" defaultValues={moduleConfig.cannedMessage} + values={getEffectiveModuleConfig("cannedMessage")} fieldGroups={[ { label: t("cannedMessage.title"), diff --git a/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx b/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx index 1ce0aaee..5d1fdb59 100644 --- a/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx +++ b/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; +} -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 ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={DetectionSensorValidationSchema} formId="ModuleConfig_DetectionSensorConfig" defaultValues={moduleConfig.detectionSensor} + values={getEffectiveModuleConfig("detectionSensor")} fieldGroups={[ { label: t("detectionSensor.title"), diff --git a/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx b/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx index 61796342..ff3141ea 100644 --- a/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx +++ b/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; +} -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 ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={ExternalNotificationValidationSchema} formId="ModuleConfig_ExternalNotificationConfig" defaultValues={moduleConfig.externalNotification} + values={getEffectiveModuleConfig("externalNotification")} fieldGroups={[ { label: t("externalNotification.title"), diff --git a/src/components/PageComponents/ModuleConfig/MQTT.tsx b/src/components/PageComponents/ModuleConfig/MQTT.tsx index e9587e89..6b6f4abd 100644 --- a/src/components/PageComponents/ModuleConfig/MQTT.tsx +++ b/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; +} -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 ( 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"), diff --git a/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx b/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx index eabb9207..389c70bc 100644 --- a/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx +++ b/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; +} -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 ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={NeighborInfoValidationSchema} formId="ModuleConfig_NeighborInfoConfig" defaultValues={moduleConfig.neighborInfo} + values={getEffectiveModuleConfig("neighborInfo")} fieldGroups={[ { label: t("neighborInfo.title"), diff --git a/src/components/PageComponents/ModuleConfig/Paxcounter.tsx b/src/components/PageComponents/ModuleConfig/Paxcounter.tsx index c11840ad..45921d73 100644 --- a/src/components/PageComponents/ModuleConfig/Paxcounter.tsx +++ b/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; +} -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 ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={PaxcounterValidationSchema} formId="ModuleConfig_PaxcounterConfig" defaultValues={moduleConfig.paxcounter} + values={getEffectiveModuleConfig("paxcounter")} fieldGroups={[ { label: t("paxcounter.title"), diff --git a/src/components/PageComponents/ModuleConfig/RangeTest.tsx b/src/components/PageComponents/ModuleConfig/RangeTest.tsx index 5960508d..fd18a110 100644 --- a/src/components/PageComponents/ModuleConfig/RangeTest.tsx +++ b/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; +} -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 ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={RangeTestValidationSchema} formId="ModuleConfig_RangeTestConfig" defaultValues={moduleConfig.rangeTest} + values={getEffectiveModuleConfig("rangeTest")} fieldGroups={[ { label: t("rangeTest.title"), diff --git a/src/components/PageComponents/ModuleConfig/Serial.tsx b/src/components/PageComponents/ModuleConfig/Serial.tsx index d98684b5..9adcac16 100644 --- a/src/components/PageComponents/ModuleConfig/Serial.tsx +++ b/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; +} -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 ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={SerialValidationSchema} formId="ModuleConfig_SerialConfig" defaultValues={moduleConfig.serial} + values={getEffectiveModuleConfig("serial")} fieldGroups={[ { label: t("serial.title"), diff --git a/src/components/PageComponents/ModuleConfig/StoreForward.tsx b/src/components/PageComponents/ModuleConfig/StoreForward.tsx index 02b5d630..155c235a 100644 --- a/src/components/PageComponents/ModuleConfig/StoreForward.tsx +++ b/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; +} -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 ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={StoreForwardValidationSchema} formId="ModuleConfig_StoreForwardConfig" defaultValues={moduleConfig.storeForward} + values={getEffectiveModuleConfig("storeForward")} fieldGroups={[ { label: t("storeForward.title"), diff --git a/src/components/PageComponents/ModuleConfig/Telemetry.tsx b/src/components/PageComponents/ModuleConfig/Telemetry.tsx index d296fe71..0bb71134 100644 --- a/src/components/PageComponents/ModuleConfig/Telemetry.tsx +++ b/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; +} -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 ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={TelemetryValidationSchema} formId="ModuleConfig_TelemetryConfig" defaultValues={moduleConfig.telemetry} + values={getEffectiveModuleConfig("telemetry")} fieldGroups={[ { label: t("telemetry.title"), 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..faa24544 100644 --- a/src/components/UI/Input.tsx +++ b/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( className, containerClassName, variant, + disabled, type = "text", prefix, suffix, @@ -137,6 +141,11 @@ const Input = React.forwardRef( className, ); + const extrasClassName = cn([ + variant === "invalid" && `${cnInvalidBase} border-l-0`, + variant === "dirty" && `${cnDirtyBase} border-l-0`, + ]); + return (
( ref={ref} value={value} onChange={onChange} + disabled={disabled} {...props} /> @@ -161,6 +171,7 @@ const Input = React.forwardRef( @@ -171,7 +182,10 @@ 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/components/UI/Sidebar/SidebarButton.tsx b/src/components/UI/Sidebar/SidebarButton.tsx index 920fb08c..8dc1aeca 100644 --- a/src/components/UI/Sidebar/SidebarButton.tsx +++ b/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} - {!isButtonCollapsed && !active && count && count > 0 && ( + {!isButtonCollapsed && ((!active && count && count > 0) || isDirty) && (
{count} diff --git a/src/core/stores/deviceStore.mock.ts b/src/core/stores/deviceStore.mock.ts index fb97c9d3..43af9a9e 100644 --- a/src/core/stores/deviceStore.mock.ts +++ b/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(), diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index 488a5d5d..5b8d20c0 100644 --- a/src/core/stores/deviceStore.ts +++ b/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] + | 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; @@ -142,6 +171,7 @@ export const useDeviceStore = createStore((set, get) => ({ refreshKeys: false, rebootOTA: false, deleteMessages: false, + managedMode: false, }, pendingSettingsChanges: false, messageDraft: "", @@ -277,6 +307,7 @@ export const useDeviceStore = createStore((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((set, get) => ({ wmc.payloadVariant.case === moduleConfig.payloadVariant.case, ); + if (index !== -1) { device.workingModuleConfig[index] = moduleConfig; } else { @@ -305,6 +337,106 @@ export const useDeviceStore = createStore((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((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/core/utils/deepCompareConfig.test.ts b/src/core/utils/deepCompareConfig.test.ts new file mode 100644 index 00000000..b504d2b7 --- /dev/null +++ b/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); + }); +}); diff --git a/src/core/utils/deepCompareConfig.ts b/src/core/utils/deepCompareConfig.ts new file mode 100644 index 00000000..aca12452 --- /dev/null +++ b/src/core/utils/deepCompareConfig.ts @@ -0,0 +1,58 @@ +function isObject(value: unknown): value is Record { + 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; +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 5597f53a..0fb0756b 100644 --- a/src/i18n/locales/en/common.json +++ b/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}}.", diff --git a/src/i18n/locales/en/dialog.json b/src/i18n/locales/en/dialog.json index 28427867..ddf1f352 100644 --- a/src/i18n/locales/en/dialog.json +++ b/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 only be changed through Remote Admin messages. This setting is not required for remote node administration." } } diff --git a/src/pages/Config/DeviceConfig.tsx b/src/pages/Config/DeviceConfig.tsx index 8a42d0c4..314d5f26 100644 --- a/src/pages/Config/DeviceConfig.tsx +++ b/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) => 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 +91,27 @@ export const DeviceConfig = () => { {tab.label} + {flags.get(tab.case) && ( + + + + + + + )} ))} {tabs.map((tab) => ( - + }> + + + + ))} diff --git a/src/pages/Config/ModuleConfig.tsx b/src/pages/Config/ModuleConfig.tsx index 4800cf2b..db0cb106 100644 --- a/src/pages/Config/ModuleConfig.tsx +++ b/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) => void; +} +type TabItem = { + case: ValidModuleConfigType; + label: string; + element: ComponentType; + 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 ( @@ -78,15 +114,27 @@ export const ModuleConfig = () => { {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..98725b0f 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, + setConfig, + setModuleConfig, + } = 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({ @@ -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( () => ( @@ -83,19 +127,79 @@ const ConfigPage = () => { active={activeConfigSection === "device"} onClick={() => setActiveConfigSection("device")} Icon={SettingsIcon} + isDirty={workingConfig.length > 0} + count={workingConfig.length} /> setActiveConfigSection("module")} Icon={BoxesIcon} + isDirty={workingModuleConfig.length > 0} + count={workingModuleConfig.length} /> ), - [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 ( <> { 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.test.ts b/src/validation/config/security.test.ts index 08e74cce..428711c3 100644 --- a/src/validation/config/security.test.ts +++ b/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); } 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, diff --git a/src/validation/moduleConfig/mqtt.ts b/src/validation/moduleConfig/mqtt.ts index 638f076f..fe47c93d 100644 --- a/src/validation/moduleConfig/mqtt.ts +++ b/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({