Browse Source

Config reset WIP

pull/652/head
philon- 1 year ago
committed by philon-
parent
commit
0444357713
  1. 12
      src/components/Form/DynamicForm.tsx
  2. 26
      src/components/Form/DynamicFormField.tsx
  3. 11
      src/components/Form/FormInput.tsx
  4. 15
      src/components/Form/FormMultiSelect.tsx
  5. 3
      src/components/Form/FormPasswordGenerator.tsx
  6. 14
      src/components/Form/FormSelect.tsx
  7. 16
      src/components/Form/FormToggle.tsx
  8. 10
      src/components/PageComponents/Config/Bluetooth.tsx
  9. 9
      src/components/PageComponents/Config/Device/index.tsx
  10. 9
      src/components/PageComponents/Config/Display.tsx
  11. 9
      src/components/PageComponents/Config/LoRa.tsx
  12. 35
      src/components/PageComponents/Config/Network/index.tsx
  13. 9
      src/components/PageComponents/Config/Position.tsx
  14. 9
      src/components/PageComponents/Config/Power.tsx
  15. 9
      src/components/PageComponents/Config/Security/Security.tsx
  16. 28
      src/components/PageComponents/ModuleConfig/AmbientLighting.tsx
  17. 26
      src/components/PageComponents/ModuleConfig/Audio.tsx
  18. 28
      src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  19. 28
      src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
  20. 28
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  21. 48
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  22. 26
      src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
  23. 26
      src/components/PageComponents/ModuleConfig/Paxcounter.tsx
  24. 26
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  25. 26
      src/components/PageComponents/ModuleConfig/Serial.tsx
  26. 26
      src/components/PageComponents/ModuleConfig/StoreForward.tsx
  27. 26
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  28. 16
      src/components/UI/Input.tsx
  29. 7
      src/components/UI/Sidebar/SidebarButton.tsx
  30. 22
      src/core/stores/deviceStore.ts
  31. 60
      src/core/utils/deepCompareConfig.test.ts
  32. 68
      src/core/utils/deepCompareConfig.ts
  33. 60
      src/pages/Config/ModuleConfig.tsx
  34. 83
      src/pages/Config/index.tsx

12
src/components/Form/DynamicForm.tsx

@ -45,6 +45,8 @@ export interface GenericFormElementProps<T extends FieldValues, Y> {
control: Control<T>;
disabled?: boolean;
field: Y;
isDirty?: boolean;
invalid?: boolean;
}
export interface DynamicFormProps<T extends FieldValues> {
@ -104,7 +106,8 @@ export function DynamicForm<T extends FieldValues>({
values,
});
}
const { handleSubmit, control, getValues, formState } = methods;
const { handleSubmit, control, getValues, formState, getFieldState } =
methods;
useEffect(() => {
if (!propMethods) {
@ -130,10 +133,7 @@ export function DynamicForm<T extends FieldValues>({
}
}
}, [formState.errors]);
console.debug(
`DynamicForm: ${formId} - Form state:`,
formState.isDirty,
);
const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,
@ -194,6 +194,8 @@ export function DynamicForm<T extends FieldValues>({
field={field}
control={control}
disabled={isDisabled(field.disabledBy, field.disabled)}
isDirty={getFieldState(field.name).isDirty}
invalid={getFieldState(field.name).invalid}
/>
</FieldWrapper>
);

26
src/components/Form/DynamicFormField.tsx

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

11
src/components/Form/FormInput.tsx

@ -31,17 +31,18 @@ export function GenericInput<T extends FieldValues>({
control,
disabled,
field,
isDirty,
invalid,
}: GenericFormElementProps<T, InputFieldProps<T>>) {
const { fieldLength, ...restProperties } = field.properties || {};
const [currentLength, setCurrentLength] = useState<number>(
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<HTMLInputElement>) => {
const newValue = e.target.value;

15
src/components/Form/FormMultiSelect.tsx

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

3
src/components/Form/FormPasswordGenerator.tsx

@ -30,6 +30,8 @@ export function PasswordGenerator<T extends FieldValues>({
control,
field,
disabled,
isDirty,
invalid,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const { isVisible } = usePasswordVisibilityToggle();
const { trigger } = useFormContext();
@ -45,7 +47,6 @@ export function PasswordGenerator<T extends FieldValues>({
render={(
{
field: { value, onChange, ...rest },
fieldState: { invalid, isDirty },
},
) => (
<Generator

14
src/components/Form/FormSelect.tsx

@ -10,6 +10,7 @@ import {
SelectValue,
} from "@components/UI/Select.tsx";
import { type FieldValues, useController } from "react-hook-form";
import { cn } from "@core/utils/cn.ts";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select";
@ -38,6 +39,8 @@ export function SelectInput<T extends FieldValues>({
control,
disabled,
field,
isDirty,
invalid,
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
const {
field: { value, onChange, ...rest },
@ -80,7 +83,16 @@ export function SelectInput<T extends FieldValues>({
{...remainingProperties}
{...rest}
>
<SelectTrigger id={field.name}>
<SelectTrigger
id={field.name}
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 outline-offset-2"
: "",
])}
>
<SelectValue />
</SelectTrigger>
<SelectContent>

16
src/components/Form/FormToggle.tsx

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

10
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<BluetoothValidation>;
}
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: {

9
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<DeviceValidation>;
}
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: {

9
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<DisplayValidation>;
}
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: {

9
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<LoRaValidation>;
}
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: {

35
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<NetworkValidation>;
}
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,
},
}),
);

9
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<PositionValidation>;
}
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: {

9
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<PowerValidation>;
}
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: {

9
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<RawSecurity>;
}
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: {

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

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

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

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

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

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

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

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

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

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

48
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<MqttValidation>;
}
export const MQTT = () => {
const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
const {
config,
moduleConfig,
setWorkingModuleConfig,
getEffectiveModuleConfig,
removeWorkingModuleConfig,
} = useDevice();
const { t } = useTranslation("moduleConfig");
const onSubmit = (data: MqttValidation) => {
const payload = {
...data,
mapReportSettings: create(
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
data.mapReportSettings,
),
};
if (deepCompareConfig(moduleConfig.mqtt, payload, true)) {
removeWorkingModuleConfig("mqtt");
return;
}
setWorkingModuleConfig(
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "mqtt",
value: {
...data,
mapReportSettings: create(
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
data.mapReportSettings,
),
},
value: payload,
},
}),
);
@ -32,9 +53,16 @@ export const MQTT = () => {
return (
<DynamicForm<MqttValidation>
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"),

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

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

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

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

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

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

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

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

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

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

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

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

16
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<HTMLInputElement, InputProps>(
<span
className={cn(
"inline-flex items-center border border-l-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300",
variant === "invalid" && "border-red-500 dark:border-red-500",
variant === "dirty" && "border-sky-500 dark:border-sky-500",
variant === "invalid" &&
"border-2 border-l-0 border-red-500 dark:border-red-500",
variant === "dirty" &&
"border-2 border-l-0 border-sky-500 dark:border-sky-500",
!hasActions && "rounded-r-md",
)}
>
@ -178,8 +180,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<div
className={cn(
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-500",
variant === "invalid" && "border-red-500 dark:border-red-500",
variant === "dirty" && "border-sky-500 dark:border-sky-500",
variant === "invalid" &&
"border-2 border-l-0 border-red-500 dark:border-red-500",
variant === "dirty" &&
"border-2 border-l-0 border-sky-500 dark:border-sky-500",
disabled &&
"border-slate-200 dark:border-slate-700 divide-slate-200",
!hasSuffix && "rounded-r-md",

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

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

22
src/core/stores/deviceStore.ts

@ -308,15 +308,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((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<PrivateDeviceState>((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);

60
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);
});
});

68
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<string, unknown>;
const woObj = working as Record<string, unknown>;
const keys = new Set<string>([
...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;
}

60
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<any>) => void;
}
type TabItem = {
case: ValidModuleConfigType;
label: string;
element: ComponentType<ConfigProps>;
count?: number;
};
export const ModuleConfig = () => {
export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
const { getWorkingModuleConfig } = useDevice();
const { t } = useTranslation("moduleConfig");
const tabs = [
const tabs: TabItem[] = [
{
case: "mqtt",
label: t("page.tabMqtt"),
element: MQTT,
},
{
case: "serial",
label: t("page.tabSerial"),
element: Serial,
},
{
case: "externalNotification",
label: t("page.tabExternalNotification"),
element: ExternalNotification,
},
{
case: "storeForward",
label: t("page.tabStoreAndForward"),
element: StoreForward,
},
{
case: "rangeTest",
label: t("page.tabRangeTest"),
element: RangeTest,
},
{
case: "telemetry",
label: t("page.tabTelemetry"),
element: Telemetry,
},
{
case: "cannedMessage",
label: t("page.tabCannedMessage"),
element: CannedMessage,
},
{
case: "audio",
label: t("page.tabAudio"),
element: Audio,
},
{
case: "neighborInfo",
label: t("page.tabNeighborInfo"),
element: NeighborInfo,
},
{
case: "ambientLighting",
label: t("page.tabAmbientLighting"),
element: AmbientLighting,
},
{
case: "detectionSensor",
label: t("page.tabDetectionSensor"),
element: DetectionSensor,
},
{
label: t("page.tabPaxcounter"),
element: Paxcounter,
},
];
{ case: "paxcounter", label: t("page.tabPaxcounter"), element: Paxcounter },
] as const;
const flags = useMemo(
() =>
new Map(tabs.map((tab) => [tab.case, getWorkingModuleConfig(tab.case)])),
[tabs, getWorkingModuleConfig],
);
return (
<Tabs defaultValue={t("page.tabMqtt")}>
@ -78,15 +112,23 @@ export const ModuleConfig = () => {
<TabsTrigger
key={tab.label}
value={tab.label}
className="dark:text-white"
className="dark:text-white relative"
>
{tab.label}
{flags.get(tab.case) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25">
</span>
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
</span>
</span>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<tab.element />
<tab.element onFormInit={onFormInit} />
</TabsContent>
))}
</Tabs>

83
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}
/>
<SidebarButton
label={t("navigation.moduleConfig")}
active={activeConfigSection === "module"}
onClick={() => setActiveConfigSection("module")}
Icon={BoxesIcon}
isDirty={workingModuleConfig.length > 0}
count={workingModuleConfig.length}
/>
</SidebarSection>
</Sidebar>
),
[activeConfigSection],
[activeConfigSection, workingConfig, workingModuleConfig],
);
const buttonOpacity = useMemo(
() => (formMethods?.formState.isDirty ||
(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"
? <DeviceConfig onFormInit={onFormInit} />
: <ModuleConfig />}
: <ModuleConfig onFormInit={onFormInit} />}
</PageLayout>
</>
);

Loading…
Cancel
Save