diff --git a/src/components/Form/DynamicForm.tsx b/src/components/Form/DynamicForm.tsx index fe55a0e0..3fe802d6 100644 --- a/src/components/Form/DynamicForm.tsx +++ b/src/components/Form/DynamicForm.tsx @@ -45,6 +45,8 @@ export interface GenericFormElementProps { control: Control; disabled?: boolean; field: Y; + isDirty?: boolean; + invalid?: boolean; } export interface DynamicFormProps { @@ -104,7 +106,8 @@ export function DynamicForm({ values, }); } - const { handleSubmit, control, getValues, formState } = methods; + const { handleSubmit, control, getValues, formState, getFieldState } = + methods; useEffect(() => { if (!propMethods) { @@ -130,10 +133,7 @@ export function DynamicForm({ } } }, [formState.errors]); - console.debug( - `DynamicForm: ${formId} - Form state:`, - formState.isDirty, - ); + const isDisabled = ( disabledBy?: DisabledBy[], disabled?: boolean, @@ -194,6 +194,8 @@ export function DynamicForm({ field={field} control={control} disabled={isDisabled(field.disabledBy, field.disabled)} + isDirty={getFieldState(field.name).isDirty} + invalid={getFieldState(field.name).invalid} /> ); 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 98925e21..cd7ffea0 100644 --- a/src/components/Form/FormInput.tsx +++ b/src/components/Form/FormInput.tsx @@ -31,17 +31,18 @@ export function GenericInput({ control, disabled, field, + isDirty, + invalid, }: GenericFormElementProps>) { const { fieldLength, ...restProperties } = field.properties || {}; const [currentLength, setCurrentLength] = useState( fieldLength?.currentValueLength || 0, ); - const { field: controllerField, fieldState: { invalid, isDirty } } = - useController({ - name: field.name, - control, - }); + const { field: controllerField } = useController({ + name: field.name, + control, + }); const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; 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(); @@ -45,7 +47,6 @@ export function PasswordGenerator({ render={( { field: { value, onChange, ...rest }, - fieldState: { invalid, isDirty }, }, ) => ( extends BaseFormBuilderProps { type: "select"; @@ -38,6 +39,8 @@ export function SelectInput({ control, disabled, field, + isDirty, + invalid, }: GenericFormElementProps>) { const { field: { value, onChange, ...rest }, @@ -80,7 +83,16 @@ export function SelectInput({ {...remainingProperties} {...rest} > - + diff --git a/src/components/Form/FormToggle.tsx b/src/components/Form/FormToggle.tsx index 4b006c2f..6351ef51 100644 --- a/src/components/Form/FormToggle.tsx +++ b/src/components/Form/FormToggle.tsx @@ -4,6 +4,7 @@ import type { } from "@components/Form/DynamicForm.tsx"; import { Switch } from "@components/UI/Switch.tsx"; import { Controller, type FieldValues } from "react-hook-form"; +import { cn } from "@core/utils/cn.ts"; export interface ToggleFieldProps 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 0f06a49e..5d27c4a1 100644 --- a/src/components/PageComponents/Config/Bluetooth.tsx +++ b/src/components/PageComponents/Config/Bluetooth.tsx @@ -10,14 +10,22 @@ import { 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 BluetoothConfigProps { onFormInit: DynamicFormFormInit; } export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => { - const { config, setWorkingConfig, getEffectiveConfig } = useDevice(); + 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: { diff --git a/src/components/PageComponents/Config/Device/index.tsx b/src/components/PageComponents/Config/Device/index.tsx index 7b71e6c4..2d1689b2 100644 --- a/src/components/PageComponents/Config/Device/index.tsx +++ b/src/components/PageComponents/Config/Device/index.tsx @@ -11,16 +11,23 @@ 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"; interface DeviceConfigProps { onFormInit: DynamicFormFormInit; } export const Device = ({ onFormInit }: DeviceConfigProps) => { - const { config, setWorkingConfig, getEffectiveConfig } = useDevice(); + 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: { diff --git a/src/components/PageComponents/Config/Display.tsx b/src/components/PageComponents/Config/Display.tsx index 43e0de4c..42e0e546 100644 --- a/src/components/PageComponents/Config/Display.tsx +++ b/src/components/PageComponents/Config/Display.tsx @@ -10,15 +10,22 @@ import { 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 DisplayConfigProps { onFormInit: DynamicFormFormInit; } export const Display = ({ onFormInit }: DisplayConfigProps) => { - const { config, setWorkingConfig, getEffectiveConfig } = useDevice(); + 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: { diff --git a/src/components/PageComponents/Config/LoRa.tsx b/src/components/PageComponents/Config/LoRa.tsx index 9b961ffd..251f2fcb 100644 --- a/src/components/PageComponents/Config/LoRa.tsx +++ b/src/components/PageComponents/Config/LoRa.tsx @@ -10,15 +10,22 @@ import { 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 LoRaConfigProps { onFormInit: DynamicFormFormInit; } export const LoRa = ({ onFormInit }: LoRaConfigProps) => { - const { config, setWorkingConfig, getEffectiveConfig } = useDevice(); + 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: { diff --git a/src/components/PageComponents/Config/Network/index.tsx b/src/components/PageComponents/Config/Network/index.tsx index 771f1f2b..12e72362 100644 --- a/src/components/PageComponents/Config/Network/index.tsx +++ b/src/components/PageComponents/Config/Network/index.tsx @@ -14,33 +14,42 @@ import { } from "@core/utils/ip.ts"; import { Protobuf } from "@meshtastic/core"; import { useTranslation } from "react-i18next"; +import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts"; interface NetworkConfigProps { onFormInit: DynamicFormFormInit; } export const Network = ({ onFormInit }: NetworkConfigProps) => { - const { config, setWorkingConfig, getEffectiveConfig } = useDevice(); + 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, }, }), ); diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index c98e2680..d6fa1a05 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -15,18 +15,25 @@ 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"; interface PositionConfigProps { onFormInit: DynamicFormFormInit; } export const Position = ({ onFormInit }: PositionConfigProps) => { - const { setWorkingConfig, config, getEffectiveConfig } = useDevice(); + const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } = + useDevice(); const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags( 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: { diff --git a/src/components/PageComponents/Config/Power.tsx b/src/components/PageComponents/Config/Power.tsx index 5d5fa92a..c35c543d 100644 --- a/src/components/PageComponents/Config/Power.tsx +++ b/src/components/PageComponents/Config/Power.tsx @@ -10,15 +10,22 @@ import { 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 PowerConfigProps { onFormInit: DynamicFormFormInit; } export const Power = ({ onFormInit }: PowerConfigProps) => { - const { setWorkingConfig, config, getEffectiveConfig } = useDevice(); + 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: { diff --git a/src/components/PageComponents/Config/Security/Security.tsx b/src/components/PageComponents/Config/Security/Security.tsx index 3b475968..dfcc685e 100644 --- a/src/components/PageComponents/Config/Security/Security.tsx +++ b/src/components/PageComponents/Config/Security/Security.tsx @@ -18,14 +18,18 @@ 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"; + interface SecurityConfigProps { onFormInit: DynamicFormFormInit; } export const Security = ({ onFormInit }: SecurityConfigProps) => { const { + config, setWorkingConfig, setDialogOpen, getEffectiveConfig, + removeWorkingConfig, } = useDevice(); const { removeError } = useAppStore(); @@ -86,6 +90,11 @@ export const Security = ({ onFormInit }: SecurityConfigProps) => { ], }; + if (deepCompareConfig(config.security, payload, true)) { + removeWorkingConfig("security"); + return; + } + setWorkingConfig( create(Protobuf.Config.ConfigSchema, { payloadVariant: { 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..a755ae2a 100644 --- a/src/components/PageComponents/ModuleConfig/MQTT.tsx +++ b/src/components/PageComponents/ModuleConfig/MQTT.tsx @@ -4,26 +4,47 @@ 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, }, }), ); @@ -32,9 +53,16 @@ export const MQTT = () => { return ( onSubmit={onSubmit} + onFormInit={onFormInit} validationSchema={MqttValidationSchema} formId="ModuleConfig_MqttConfig" defaultValues={moduleConfig.mqtt} + values={(() => { + const cfg = getEffectiveModuleConfig("mqtt"); + return cfg + ? { ...cfg, mapReportSettings: cfg.mapReportSettings ?? {} } + : undefined; + })()} 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/UI/Input.tsx b/src/components/UI/Input.tsx index a0cf627e..c298357e 100644 --- a/src/components/UI/Input.tsx +++ b/src/components/UI/Input.tsx @@ -13,9 +13,9 @@ const inputVariants = cva( 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", + "border-2 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", + "border-2 border-sky-500 dark:border-sky-500 focus:ring-sky-500 dark:focus:ring-sky-500", }, }, defaultVariants: { @@ -165,8 +165,10 @@ const Input = React.forwardRef( @@ -178,8 +180,10 @@ const Input = React.forwardRef(
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.ts b/src/core/stores/deviceStore.ts index fcc90560..7735dec9 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -308,15 +308,7 @@ export const useDeviceStore = createStore((set, get) => ({ (wc) => wc.payloadVariant.case === config.payloadVariant.case, ); - 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) { + if (index !== -1) { device.workingConfig[index] = config; } else { device.workingConfig.push(config); @@ -336,16 +328,8 @@ export const useDeviceStore = createStore((set, get) => ({ wmc.payloadVariant.case === moduleConfig.payloadVariant.case, ); - if ( - JSON.stringify( - current( - device.moduleConfig[moduleConfig.payloadVariant.case], - ), - ) === - JSON.stringify(moduleConfig.payloadVariant.value) - ) { - device.workingModuleConfig.splice(index, 1); - } else if (index !== -1) { + + if (index !== -1) { device.workingModuleConfig[index] = moduleConfig; } else { device.workingModuleConfig.push(moduleConfig); diff --git a/src/core/utils/deepCompareConfig.test.ts b/src/core/utils/deepCompareConfig.test.ts new file mode 100644 index 00000000..8dd94713 --- /dev/null +++ b/src/core/utils/deepCompareConfig.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { deepCompareConfig } from "./deepCompareConfig"; + +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..75677c7a --- /dev/null +++ b/src/core/utils/deepCompareConfig.ts @@ -0,0 +1,68 @@ +export function deepCompareConfig( + existing: unknown, + working: unknown, + allowUndefined = false, +): boolean { + if (existing === working) return true; + + if ( + allowUndefined && + (typeof existing === "undefined" || typeof working === "undefined") + ) return true; + + if (typeof existing !== typeof working) { + return false; + } + + if (existing === null || working === null) { + if (existing !== working) { + return false; + } + return true; + } + + if (Array.isArray(existing) && Array.isArray(working)) { + if (existing.length !== working.length && !allowUndefined) { + return false; + } + for (let i = 0; i < existing.length; i++) { + if ( + !deepCompareConfig(existing[i], working[i], allowUndefined) + ) { + return false; + } + } + return true; + } + + if (typeof existing === "object" && typeof working === "object") { + const exObj = existing as Record; + const woObj = working as Record; + const keys = new Set([ + ...Object.keys(exObj), + ...Object.keys(woObj), + ]); + + for (const key of keys) { + if (key === "$typeName") continue; + const hasExisting = Object.prototype.hasOwnProperty.call(exObj, key); + const hasWorking = Object.prototype.hasOwnProperty.call(woObj, key); + const valExisting = exObj[key]; + const valWorking = woObj[key]; + + if (!hasWorking && allowUndefined && hasExisting) { + continue; + } + + if ( + !deepCompareConfig(valExisting, valWorking, allowUndefined) + ) { + return false; + } + } + + return true; + } + + return false; +} diff --git a/src/pages/Config/ModuleConfig.tsx b/src/pages/Config/ModuleConfig.tsx index 4800cf2b..4803a6f1 100644 --- a/src/pages/Config/ModuleConfig.tsx +++ b/src/pages/Config/ModuleConfig.tsx @@ -17,59 +17,93 @@ import { TabsTrigger, } from "@components/UI/Tabs.tsx"; import { useTranslation } from "react-i18next"; +import { + useDevice, + type ValidModuleConfigType, +} from "@core/stores/deviceStore.ts"; +import { useMemo } from "react"; +import type { ComponentType } from "react"; +import type { UseFormReturn } from "react-hook-form"; + +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 +112,23 @@ 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 d4720224..f71ab058 100644 --- a/src/pages/Config/index.tsx +++ b/src/pages/Config/index.tsx @@ -58,40 +58,36 @@ 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, - }), - }) - ) - ), - ); - setIsSaving(false); - } + 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, + }), + }) + ) + ), + ); + + 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 connection?.commitEditSettings().then(() => { - if (activeConfigSection === "device") { - removeWorkingConfig(); - } else { - removeWorkingModuleConfig(); - } + removeWorkingConfig(); + removeWorkingModuleConfig(); + if (formMethods) { formMethods.reset({ keepDefaultValues: true, @@ -115,11 +111,8 @@ const ConfigPage = () => { formMethods.reset(); } - if (activeConfigSection === "device") { - removeWorkingConfig(); - } else { - removeWorkingModuleConfig(); - } + removeWorkingConfig(); + removeWorkingModuleConfig(); }; const leftSidebar = useMemo( @@ -134,23 +127,26 @@ 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 || - (activeConfigSection === "device" && workingConfig.length > 0) || - (activeConfigSection === "module" && workingModuleConfig.length > 0) + workingConfig.length > 0 || workingModuleConfig.length > 0 ? "opacity-100" : "opacity-0"), [formMethods?.formState.isDirty, workingConfig, workingModuleConfig], @@ -179,8 +175,7 @@ const ConfigPage = () => { icon: isError ? SaveOff : SaveIcon, isLoading: isSaving, disabled: isSaving || - (activeConfigSection === "device" && workingConfig.length === 0) || - (activeConfigSection === "module" && workingModuleConfig.length === 0), + (workingConfig.length === 0 && workingModuleConfig.length === 0), iconClasses: isError ? "text-red-400 cursor-not-allowed" : "", onClick: handleSave, label: t("common:button.save"), @@ -204,7 +199,7 @@ const ConfigPage = () => { > {activeConfigSection === "device" ? - : } + : } );