Config form improvements (#652)
* Config reset work WIP * Config reset WIP * Fix tests, tsc, linting * Form reset adjustments * Add ManagedModeDialog * Remove debug logging * Add Suspense * Review fixes --------- Co-authored-by: philon- <philon-@users.noreply.github.com>
This commit is contained in:
co-authored by
philon-
parent
7adbe01723
commit
181c984b27
@@ -0,0 +1,72 @@
|
||||
import { Button } from "@components/UI/Button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.tsx";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface ManagedModeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export const ManagedModeDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: ManagedModeDialogProps) => {
|
||||
const { t } = useTranslation("dialog");
|
||||
const [confirmState, setConfirmState] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogClose />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("managedMode.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans
|
||||
i18nKey="managedMode.description"
|
||||
components={{
|
||||
"bold": <p className="font-bold inline" />,
|
||||
}}
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="managedMode"
|
||||
checked={confirmState}
|
||||
onChange={() => setConfirmState(!confirmState)}
|
||||
name="confirmUnderstanding"
|
||||
>
|
||||
<p className="dark:text-white pt-1">
|
||||
{t("managedMode.confirmUnderstanding")}
|
||||
</p>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
name="regenerate"
|
||||
disabled={!confirmState}
|
||||
onClick={() => {
|
||||
setConfirmState(false);
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
{t("button.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type Path,
|
||||
type SubmitHandler,
|
||||
useForm,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||
import { ZodType } from "zod/v4";
|
||||
@@ -44,13 +45,18 @@ export interface GenericFormElementProps<T extends FieldValues, Y> {
|
||||
control: Control<T>;
|
||||
disabled?: boolean;
|
||||
field: Y;
|
||||
isDirty?: boolean;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
export interface DynamicFormProps<T extends FieldValues> {
|
||||
propMethods?: UseFormReturn<T, T, T>;
|
||||
onSubmit: SubmitHandler<T>;
|
||||
onFormInit?: DynamicFormFormInit<T>;
|
||||
submitType?: "onChange" | "onSubmit";
|
||||
hasSubmitButton?: boolean;
|
||||
defaultValues?: DefaultValues<T>;
|
||||
values?: T;
|
||||
fieldGroups: {
|
||||
label: string;
|
||||
description: string;
|
||||
@@ -63,11 +69,18 @@ export interface DynamicFormProps<T extends FieldValues> {
|
||||
formId?: string;
|
||||
}
|
||||
|
||||
export type DynamicFormFormInit<T extends FieldValues> = (
|
||||
methods: UseFormReturn<T, T, T>,
|
||||
) => void;
|
||||
|
||||
export function DynamicForm<T extends FieldValues>({
|
||||
propMethods,
|
||||
onSubmit,
|
||||
onFormInit,
|
||||
submitType = "onChange",
|
||||
hasSubmitButton,
|
||||
defaultValues,
|
||||
values,
|
||||
fieldGroups,
|
||||
validationSchema,
|
||||
formId,
|
||||
@@ -78,17 +91,29 @@ export function DynamicForm<T extends FieldValues>({
|
||||
removeError,
|
||||
} = useAppStore();
|
||||
|
||||
const methods = useForm<
|
||||
T
|
||||
>({
|
||||
mode: "onChange",
|
||||
defaultValues: defaultValues,
|
||||
resolver: validationSchema
|
||||
? createZodResolver(validationSchema)
|
||||
: undefined,
|
||||
shouldFocusError: false,
|
||||
});
|
||||
const { handleSubmit, control, getValues, formState } = methods;
|
||||
let methods = propMethods;
|
||||
if (!methods) {
|
||||
methods = useForm<
|
||||
T
|
||||
>({
|
||||
mode: "onChange",
|
||||
defaultValues: defaultValues,
|
||||
resolver: validationSchema
|
||||
? createZodResolver(validationSchema)
|
||||
: undefined,
|
||||
shouldFocusError: false,
|
||||
resetOptions: { keepDefaultValues: true },
|
||||
values,
|
||||
});
|
||||
}
|
||||
const { handleSubmit, control, getValues, formState, getFieldState } =
|
||||
methods;
|
||||
|
||||
useEffect(() => {
|
||||
if (!propMethods) {
|
||||
onFormInit?.(methods);
|
||||
}
|
||||
}, [onFormInit, propMethods, methods]);
|
||||
|
||||
useEffect(() => {
|
||||
const errorKeys = Object.keys(formState.errors);
|
||||
@@ -155,25 +180,22 @@ export function DynamicForm<T extends FieldValues>({
|
||||
label={field.label}
|
||||
fieldName={field.name}
|
||||
description={field.description}
|
||||
valid={validationSchema // keep backwards compat with not updated cfg pages
|
||||
? !error
|
||||
: field.validationText === undefined ||
|
||||
field.validationText === ""}
|
||||
validationText={validationSchema
|
||||
? (error
|
||||
? String(
|
||||
t([`formValidation.${error.type}`, error.message], {
|
||||
returnObjects: false,
|
||||
...error.params,
|
||||
}),
|
||||
)
|
||||
: "")
|
||||
: field.validationText}
|
||||
valid={!error}
|
||||
validationText={error
|
||||
? String(
|
||||
t([`formValidation.${error.type}`, error.message], {
|
||||
returnObjects: false,
|
||||
...error.params,
|
||||
}),
|
||||
)
|
||||
: ""}
|
||||
>
|
||||
<DynamicFormField
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={isDisabled(field.disabledBy, field.disabled)}
|
||||
isDirty={getFieldState(field.name).isDirty}
|
||||
invalid={getFieldState(field.name).invalid}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ 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>(
|
||||
@@ -78,6 +80,7 @@ export function GenericInput<T extends FieldValues>({
|
||||
className={field.properties?.className}
|
||||
{...restProperties}
|
||||
disabled={disabled}
|
||||
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
|
||||
/>
|
||||
|
||||
{fieldLength?.showCharacterCount && fieldLength?.max && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -42,7 +44,11 @@ export function PasswordGenerator<T extends FieldValues>({
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
render={({ field: { value, onChange, ...rest } }) => (
|
||||
render={(
|
||||
{
|
||||
field: { value, onChange, ...rest },
|
||||
},
|
||||
) => (
|
||||
<Generator
|
||||
type={field.hide && !isVisible ? "password" : "text"}
|
||||
id={field.id}
|
||||
@@ -54,7 +60,7 @@ export function PasswordGenerator<T extends FieldValues>({
|
||||
}}
|
||||
selectChange={field.selectChange ?? (() => {})}
|
||||
value={value}
|
||||
variant={field.validationText ? "invalid" : "default"}
|
||||
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
|
||||
actionButtons={field.actionButtons}
|
||||
showPasswordToggle={field.showPasswordToggle}
|
||||
showCopyButton={field.showCopyButton}
|
||||
|
||||
@@ -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, ref, onBlur, ...rest },
|
||||
@@ -46,8 +49,13 @@ export function SelectInput<T extends FieldValues>({
|
||||
control,
|
||||
});
|
||||
|
||||
const { enumValue, formatEnumName, defaultValue, ...remainingProperties } =
|
||||
field.properties;
|
||||
const {
|
||||
enumValue,
|
||||
formatEnumName,
|
||||
defaultValue,
|
||||
className,
|
||||
...remainingProperties
|
||||
} = field.properties;
|
||||
const valueToKeyMap: Record<string, string> = {};
|
||||
const optionsEnumValues: [string, number][] = [];
|
||||
|
||||
@@ -82,6 +90,13 @@ export function SelectInput<T extends FieldValues>({
|
||||
>
|
||||
<SelectTrigger
|
||||
id={field.name}
|
||||
className={cn([
|
||||
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"
|
||||
: "",
|
||||
])}
|
||||
ref={ref}
|
||||
onBlur={onBlur}
|
||||
{...remainingProperties}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,16 +3,29 @@ import {
|
||||
BluetoothValidationSchema,
|
||||
} from "@app/validation/config/bluetooth.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
export const Bluetooth = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
interface BluetoothConfigProps {
|
||||
onFormInit: DynamicFormFormInit<BluetoothValidation>;
|
||||
}
|
||||
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onSubmit = (data: BluetoothValidation) => {
|
||||
if (deepCompareConfig(config.bluetooth, data, true)) {
|
||||
removeWorkingConfig("bluetooth");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
@@ -26,9 +39,11 @@ export const Bluetooth = () => {
|
||||
return (
|
||||
<DynamicForm<BluetoothValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={BluetoothValidationSchema}
|
||||
formId="Config_BluetoothConfig"
|
||||
defaultValues={config.bluetooth}
|
||||
values={getEffectiveConfig("bluetooth")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("bluetooth.title"),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
useDevice,
|
||||
ValidConfigType,
|
||||
ValidModuleConfigType,
|
||||
} from "@core/stores/deviceStore.ts";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ConfigSuspender({
|
||||
configCase,
|
||||
moduleConfigCase,
|
||||
children,
|
||||
}: {
|
||||
configCase?: ValidConfigType;
|
||||
moduleConfigCase?: ValidModuleConfigType;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { config, moduleConfig } = useDevice();
|
||||
|
||||
let cfg = undefined;
|
||||
if (configCase) {
|
||||
cfg = config[configCase];
|
||||
} else if (moduleConfigCase) {
|
||||
cfg = moduleConfig[moduleConfigCase];
|
||||
} else {
|
||||
return children;
|
||||
}
|
||||
|
||||
const [ready, setReady] = useState(() => cfg !== undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (cfg !== undefined) setReady(true);
|
||||
}, [cfg]);
|
||||
|
||||
if (!ready) throw new Promise(() => {}); // triggers suspense fallback
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -3,18 +3,31 @@ import {
|
||||
DeviceValidationSchema,
|
||||
} from "@app/validation/config/device.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
export const Device = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
interface DeviceConfigProps {
|
||||
onFormInit: DynamicFormFormInit<DeviceValidation>;
|
||||
}
|
||||
export const Device = ({ onFormInit }: DeviceConfigProps) => {
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
const { validateRoleSelection } = useUnsafeRolesDialog();
|
||||
|
||||
const onSubmit = (data: DeviceValidation) => {
|
||||
if (deepCompareConfig(config.device, data, true)) {
|
||||
removeWorkingConfig("device");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
@@ -24,12 +37,15 @@ export const Device = () => {
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<DeviceValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={DeviceValidationSchema}
|
||||
formId="Config_DeviceConfig"
|
||||
defaultValues={config.device}
|
||||
values={getEffectiveConfig("device")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("device.title"),
|
||||
@@ -97,7 +113,8 @@ export const Device = () => {
|
||||
properties: {
|
||||
fieldLength: {
|
||||
max: 64,
|
||||
currentValueLength: config.device?.tzdef?.length,
|
||||
currentValueLength: getEffectiveConfig("device")?.tzdef
|
||||
?.length,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,16 +3,29 @@ import {
|
||||
DisplayValidationSchema,
|
||||
} from "@app/validation/config/display.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
export const Display = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
interface DisplayConfigProps {
|
||||
onFormInit: DynamicFormFormInit<DisplayValidation>;
|
||||
}
|
||||
export const Display = ({ onFormInit }: DisplayConfigProps) => {
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onSubmit = (data: DisplayValidation) => {
|
||||
if (deepCompareConfig(config.display, data, true)) {
|
||||
removeWorkingConfig("display");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
@@ -26,9 +39,11 @@ export const Display = () => {
|
||||
return (
|
||||
<DynamicForm<DisplayValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={DisplayValidationSchema}
|
||||
formId="Config_DisplayConfig"
|
||||
defaultValues={config.display}
|
||||
values={getEffectiveConfig("display")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("display.title"),
|
||||
|
||||
@@ -3,16 +3,29 @@ import {
|
||||
LoRaValidationSchema,
|
||||
} from "@app/validation/config/lora.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
export const LoRa = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
interface LoRaConfigProps {
|
||||
onFormInit: DynamicFormFormInit<LoRaValidation>;
|
||||
}
|
||||
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onSubmit = (data: LoRaValidation) => {
|
||||
if (deepCompareConfig(config.lora, data, true)) {
|
||||
removeWorkingConfig("lora");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
@@ -26,9 +39,11 @@ export const LoRa = () => {
|
||||
return (
|
||||
<DynamicForm<LoRaValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={LoRaValidationSchema}
|
||||
formId="Config_LoRaConfig"
|
||||
defaultValues={config.lora}
|
||||
values={getEffectiveConfig("lora")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("lora.title"),
|
||||
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
NetworkValidationSchema,
|
||||
} from "@app/validation/config/network.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import {
|
||||
convertIntToIpAddress,
|
||||
@@ -11,35 +14,50 @@ import {
|
||||
} from "@core/utils/ip.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
export const Network = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
interface NetworkConfigProps {
|
||||
onFormInit: DynamicFormFormInit<NetworkValidation>;
|
||||
}
|
||||
export const Network = ({ onFormInit }: NetworkConfigProps) => {
|
||||
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const networkConfig = getEffectiveConfig("network");
|
||||
|
||||
const onSubmit = (data: NetworkValidation) => {
|
||||
const payload = {
|
||||
...data,
|
||||
ipv4Config: create(
|
||||
Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema,
|
||||
{
|
||||
ip: convertIpAddressToInt(data.ipv4Config?.ip ?? ""),
|
||||
gateway: convertIpAddressToInt(data.ipv4Config?.gateway ?? ""),
|
||||
subnet: convertIpAddressToInt(data.ipv4Config?.subnet ?? ""),
|
||||
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
if (deepCompareConfig(config.network, payload, true)) {
|
||||
removeWorkingConfig("network");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "network",
|
||||
value: {
|
||||
...data,
|
||||
ipv4Config: create(
|
||||
Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema,
|
||||
{
|
||||
ip: convertIpAddressToInt(data.ipv4Config?.ip ?? ""),
|
||||
gateway: convertIpAddressToInt(data.ipv4Config?.gateway ?? ""),
|
||||
subnet: convertIpAddressToInt(data.ipv4Config?.subnet ?? ""),
|
||||
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
|
||||
},
|
||||
),
|
||||
},
|
||||
value: payload,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<NetworkValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={NetworkValidationSchema}
|
||||
formId="Config_NetworkConfig"
|
||||
defaultValues={{
|
||||
@@ -57,6 +75,21 @@ export const Network = () => {
|
||||
enabledProtocols: config.network?.enabledProtocols ??
|
||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
||||
}}
|
||||
values={{
|
||||
...networkConfig,
|
||||
ipv4Config: {
|
||||
ip: convertIntToIpAddress(networkConfig?.ipv4Config?.ip ?? 0),
|
||||
gateway: convertIntToIpAddress(
|
||||
networkConfig?.ipv4Config?.gateway ?? 0,
|
||||
),
|
||||
subnet: convertIntToIpAddress(
|
||||
networkConfig?.ipv4Config?.subnet ?? 0,
|
||||
),
|
||||
dns: convertIntToIpAddress(networkConfig?.ipv4Config?.dns ?? 0),
|
||||
},
|
||||
enabledProtocols: networkConfig?.enabledProtocols ??
|
||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
||||
} as NetworkValidation}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("network.title"),
|
||||
|
||||
@@ -7,20 +7,33 @@ import {
|
||||
PositionValidationSchema,
|
||||
} from "@app/validation/config/position.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
export const Position = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
interface PositionConfigProps {
|
||||
onFormInit: DynamicFormFormInit<PositionValidation>;
|
||||
}
|
||||
export const Position = ({ onFormInit }: PositionConfigProps) => {
|
||||
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||
config?.position?.positionFlags ?? 0,
|
||||
getEffectiveConfig("position")?.positionFlags ?? 0,
|
||||
);
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onSubmit = (data: PositionValidation) => {
|
||||
if (deepCompareConfig(config.position, data, true)) {
|
||||
removeWorkingConfig("position");
|
||||
return;
|
||||
}
|
||||
|
||||
return setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
@@ -44,9 +57,11 @@ export const Position = () => {
|
||||
data.positionFlags = flagsValue;
|
||||
return onSubmit(data);
|
||||
}}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={PositionValidationSchema}
|
||||
formId="Config_PositionConfig"
|
||||
defaultValues={config.position}
|
||||
values={getEffectiveConfig("position")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("position.title"),
|
||||
|
||||
@@ -3,16 +3,29 @@ import {
|
||||
PowerValidationSchema,
|
||||
} from "@app/validation/config/power.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
export const Power = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
interface PowerConfigProps {
|
||||
onFormInit: DynamicFormFormInit<PowerValidation>;
|
||||
}
|
||||
export const Power = ({ onFormInit }: PowerConfigProps) => {
|
||||
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
|
||||
useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onSubmit = (data: PowerValidation) => {
|
||||
if (deepCompareConfig(config.power, data, true)) {
|
||||
removeWorkingConfig("power");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
@@ -26,9 +39,11 @@ export const Power = () => {
|
||||
return (
|
||||
<DynamicForm<PowerValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={PowerValidationSchema}
|
||||
formId="Config_PowerConfig"
|
||||
defaultValues={config.power}
|
||||
values={getEffectiveConfig("power")}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("power.powerConfigSettings.label"),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { ManagedModeDialog } from "@components/Dialog/ManagedModeDialog.tsx";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
||||
import {
|
||||
@@ -7,37 +11,82 @@ import {
|
||||
type RawSecurity,
|
||||
RawSecuritySchema,
|
||||
} from "@app/validation/config/security.ts";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type DefaultValues, useForm } from "react-hook-form";
|
||||
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
type KeyState = {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
privateKeyDialogOpen: boolean;
|
||||
};
|
||||
interface SecurityConfigProps {
|
||||
onFormInit: DynamicFormFormInit<RawSecurity>;
|
||||
}
|
||||
export const Security = ({ onFormInit }: SecurityConfigProps) => {
|
||||
const {
|
||||
config,
|
||||
setWorkingConfig,
|
||||
setDialogOpen,
|
||||
getEffectiveConfig,
|
||||
removeWorkingConfig,
|
||||
} = useDevice();
|
||||
|
||||
export const Security = () => {
|
||||
const { config, setWorkingConfig, setDialogOpen } = useDevice();
|
||||
const { removeError } = useAppStore();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const [keyState, setKeyState] = useState<KeyState>(() => ({
|
||||
publicKey: fromByteArray(config?.security?.publicKey ?? new Uint8Array(0)),
|
||||
privateKey: fromByteArray(
|
||||
config?.security?.privateKey ?? new Uint8Array(0),
|
||||
),
|
||||
privateKeyDialogOpen: false,
|
||||
}));
|
||||
const securityConfig = getEffectiveConfig("security");
|
||||
const defaultValues = {
|
||||
...securityConfig,
|
||||
...{
|
||||
privateKey: fromByteArray(
|
||||
securityConfig?.privateKey ?? new Uint8Array(0),
|
||||
),
|
||||
publicKey: fromByteArray(
|
||||
securityConfig?.publicKey ?? new Uint8Array(0),
|
||||
),
|
||||
adminKey: [
|
||||
fromByteArray(
|
||||
securityConfig?.adminKey?.at(0) ?? new Uint8Array(0),
|
||||
),
|
||||
fromByteArray(
|
||||
securityConfig?.adminKey?.at(1) ?? new Uint8Array(0),
|
||||
),
|
||||
fromByteArray(
|
||||
securityConfig?.adminKey?.at(2) ?? new Uint8Array(0),
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const formMethods = useForm<RawSecurity>({
|
||||
mode: "onChange",
|
||||
defaultValues: defaultValues as DefaultValues<RawSecurity>,
|
||||
resolver: createZodResolver(RawSecuritySchema),
|
||||
shouldFocusError: false,
|
||||
resetOptions: { keepDefaultValues: true },
|
||||
});
|
||||
const { setValue, formState } = formMethods;
|
||||
|
||||
useEffect(() => {
|
||||
onFormInit?.(formMethods);
|
||||
}, [onFormInit, formMethods]);
|
||||
|
||||
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [managedModeDialogOpen, setManagedModeDialogOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
|
||||
const onSubmit = (data: RawSecurity) => {
|
||||
if (!formState.isReady) return;
|
||||
|
||||
const payload: ParsedSecurity = {
|
||||
...data,
|
||||
privateKey: toByteArray(keyState.privateKey),
|
||||
publicKey: toByteArray(keyState.publicKey),
|
||||
privateKey: toByteArray(data.privateKey),
|
||||
publicKey: toByteArray(data.publicKey),
|
||||
adminKey: [
|
||||
toByteArray(data.adminKey.at(0) ?? ""),
|
||||
toByteArray(data.adminKey.at(1) ?? ""),
|
||||
@@ -45,6 +94,11 @@ export const Security = () => {
|
||||
],
|
||||
};
|
||||
|
||||
if (deepCompareConfig(config.security, payload, true)) {
|
||||
removeWorkingConfig("security");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
create(Protobuf.Config.ConfigSchema, {
|
||||
payloadVariant: {
|
||||
@@ -54,18 +108,10 @@ export const Security = () => {
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const pkiRegenerate = () => {
|
||||
const privateKey = getX25519PrivateKey();
|
||||
|
||||
updatePublicKey(fromByteArray(privateKey));
|
||||
|
||||
setKeyState((prev) => ({
|
||||
...prev,
|
||||
privateKey: fromByteArray(privateKey),
|
||||
privateKeyDialogOpen: false,
|
||||
}));
|
||||
|
||||
removeError("privateKey");
|
||||
};
|
||||
|
||||
const updatePublicKey = (privateKey: string) => {
|
||||
@@ -73,18 +119,14 @@ export const Security = () => {
|
||||
const publicKey = fromByteArray(
|
||||
getX25519PublicKey(toByteArray(privateKey)),
|
||||
);
|
||||
setKeyState((prev) => ({
|
||||
...prev,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
}));
|
||||
setValue("privateKey", privateKey);
|
||||
setValue("publicKey", publicKey);
|
||||
|
||||
removeError("privateKey");
|
||||
removeError("publicKey");
|
||||
setPrivateKeyDialogOpen(false);
|
||||
} catch (_e) {
|
||||
setKeyState((prev) => ({
|
||||
...prev,
|
||||
privateKey: privateKey,
|
||||
}));
|
||||
setValue("privateKey", privateKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,31 +141,9 @@ export const Security = () => {
|
||||
return (
|
||||
<>
|
||||
<DynamicForm<RawSecurity>
|
||||
propMethods={formMethods}
|
||||
onSubmit={onSubmit}
|
||||
validationSchema={RawSecuritySchema}
|
||||
formId="Config_SecurityConfig"
|
||||
defaultValues={{
|
||||
...config.security,
|
||||
...{
|
||||
privateKey: fromByteArray(
|
||||
config?.security?.privateKey ?? new Uint8Array(0),
|
||||
),
|
||||
publicKey: fromByteArray(
|
||||
config?.security?.publicKey ?? new Uint8Array(0),
|
||||
),
|
||||
adminKey: [
|
||||
fromByteArray(
|
||||
config?.security?.adminKey.at(0) ?? new Uint8Array(0),
|
||||
),
|
||||
fromByteArray(
|
||||
config?.security?.adminKey.at(1) ?? new Uint8Array(0),
|
||||
),
|
||||
fromByteArray(
|
||||
config?.security?.adminKey.at(2) ?? new Uint8Array(0),
|
||||
),
|
||||
],
|
||||
},
|
||||
}}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("security.title"),
|
||||
@@ -144,11 +164,7 @@ export const Security = () => {
|
||||
actionButtons: [
|
||||
{
|
||||
text: t("button.generate"),
|
||||
onClick: () =>
|
||||
setKeyState((prev) => ({
|
||||
...prev,
|
||||
privateKeyDialogOpen: true,
|
||||
})),
|
||||
onClick: () => setPrivateKeyDialogOpen(true),
|
||||
variant: "success",
|
||||
},
|
||||
{
|
||||
@@ -160,8 +176,6 @@ export const Security = () => {
|
||||
properties: {
|
||||
showCopyButton: true,
|
||||
showPasswordToggle: true,
|
||||
|
||||
value: keyState.privateKey,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -172,7 +186,6 @@ export const Security = () => {
|
||||
description: t("security.publicKey.description"),
|
||||
properties: {
|
||||
showCopyButton: true,
|
||||
value: keyState.publicKey,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -240,6 +253,13 @@ export const Security = () => {
|
||||
name: "isManaged",
|
||||
label: t("security.managed.label"),
|
||||
description: t("security.managed.description"),
|
||||
inputChange: (checked) => {
|
||||
if (checked) {
|
||||
setManagedModeDialogOpen(true);
|
||||
}
|
||||
|
||||
setValue("isManaged", false);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
@@ -275,14 +295,19 @@ export const Security = () => {
|
||||
title: t("pkiRegenerate.title"),
|
||||
description: t("pkiRegenerate.description"),
|
||||
}}
|
||||
open={keyState.privateKeyDialogOpen}
|
||||
onOpenChange={() =>
|
||||
setKeyState((prev) => ({
|
||||
...prev,
|
||||
privateKeyDialogOpen: false,
|
||||
}))}
|
||||
open={privateKeyDialogOpen}
|
||||
onOpenChange={() => setPrivateKeyDialogOpen((prev) => !prev)}
|
||||
onSubmit={pkiRegenerate}
|
||||
/>
|
||||
|
||||
<ManagedModeDialog
|
||||
open={managedModeDialogOpen}
|
||||
onOpenChange={() => setManagedModeDialogOpen((prev) => !prev)}
|
||||
onSubmit={() => {
|
||||
setValue("isManaged", true);
|
||||
setManagedModeDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const AmbientLighting = () => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface AmbientLightingModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<AmbientLightingValidation>;
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const Audio = () => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface AudioModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<AudioValidation>;
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const CannedMessage = () => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface CannedMessageModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<CannedMessageValidation>;
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const DetectionSensor = () => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface DetectionSensorModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<DetectionSensorValidation>;
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const ExternalNotification = () => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface ExternalNotificationModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<ExternalNotificationValidation>;
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -4,37 +4,72 @@ import {
|
||||
MqttValidationSchema,
|
||||
} from "@app/validation/moduleConfig/mqtt.ts";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import {
|
||||
DynamicForm,
|
||||
type DynamicFormFormInit,
|
||||
} from "@components/Form/DynamicForm.tsx";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
|
||||
export const MQTT = () => {
|
||||
const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface MqttModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<MqttValidation>;
|
||||
}
|
||||
|
||||
export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
|
||||
const {
|
||||
config,
|
||||
moduleConfig,
|
||||
setWorkingModuleConfig,
|
||||
getEffectiveModuleConfig,
|
||||
removeWorkingModuleConfig,
|
||||
} = useDevice();
|
||||
const { t } = useTranslation("moduleConfig");
|
||||
|
||||
const onSubmit = (data: MqttValidation) => {
|
||||
const payload = {
|
||||
...data,
|
||||
mapReportSettings: create(
|
||||
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
|
||||
data.mapReportSettings,
|
||||
),
|
||||
};
|
||||
|
||||
if (deepCompareConfig(moduleConfig.mqtt, payload, true)) {
|
||||
removeWorkingModuleConfig("mqtt");
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingModuleConfig(
|
||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||
payloadVariant: {
|
||||
case: "mqtt",
|
||||
value: {
|
||||
...data,
|
||||
mapReportSettings: create(
|
||||
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
|
||||
data.mapReportSettings,
|
||||
),
|
||||
},
|
||||
value: payload,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const populateDefaultValues = (
|
||||
cfg: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined,
|
||||
) => {
|
||||
return cfg
|
||||
? {
|
||||
...cfg,
|
||||
mapReportSettings: cfg.mapReportSettings ??
|
||||
{ publishIntervalSecs: 0, positionPrecision: 10 },
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<DynamicForm<MqttValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={MqttValidationSchema}
|
||||
formId="ModuleConfig_MqttConfig"
|
||||
defaultValues={moduleConfig.mqtt}
|
||||
defaultValues={populateDefaultValues(moduleConfig.mqtt)}
|
||||
values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("mqtt.title"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const NeighborInfo = () => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface NeighborInfoModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<NeighborInfoValidation>;
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const Paxcounter = () => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface PaxcounterModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<PaxcounterValidation>;
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const RangeTest = () => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface RangeTestModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<RangeTestValidation>;
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const Serial = () => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface SerialModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<SerialValidation>;
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const StoreForward = () => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface StoreForwardModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<StoreForwardValidation>;
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const Telemetry = () => {
|
||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
||||
interface TelemetryModuleConfigProps {
|
||||
onFormInit: DynamicFormFormInit<TelemetryValidation>;
|
||||
}
|
||||
|
||||
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"),
|
||||
|
||||
@@ -8,12 +8,14 @@ import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||
|
||||
export interface ActionItem {
|
||||
key: string;
|
||||
icon: LucideIcon;
|
||||
icon?: LucideIcon;
|
||||
iconClasses?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
ariaLabel?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface PageLayoutProps {
|
||||
@@ -69,27 +71,39 @@ export const PageLayout = ({
|
||||
<span className="text-lg font-medium text-foreground truncate px-2">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center space-x-3 md:space-x-4 shrink-0">
|
||||
{actions?.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
disabled={action.disabled || action.isLoading}
|
||||
className="text-foreground transition-colors hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={action.onClick}
|
||||
aria-label={action.ariaLabel || `Action ${action.key}`}
|
||||
aria-disabled={action.disabled}
|
||||
aria-busy={action.isLoading}
|
||||
>
|
||||
<div className="mr-6">
|
||||
{action.isLoading ? <Spinner size="md" /> : (
|
||||
<action.icon
|
||||
className={cn("h-5 w-5", action.iconClasses)}
|
||||
/>
|
||||
<div className="flex items-center space-x-1 md:space-x-2 shrink-0 pr-6">
|
||||
{actions?.map((action) => {
|
||||
return (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
disabled={action.disabled || action.isLoading}
|
||||
className={cn(
|
||||
"flex items-center space-x-2 py-2 px-3 rounded-md",
|
||||
"text-foreground transition-colors hover:text-accent",
|
||||
"hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
action.className,
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
onClick={action.onClick}
|
||||
aria-label={action.ariaLabel || `Action ${action.key}`}
|
||||
aria-disabled={action.disabled}
|
||||
aria-busy={action.isLoading}
|
||||
>
|
||||
{action.icon &&
|
||||
(action.isLoading ? <Spinner size="md" /> : (
|
||||
<action.icon
|
||||
className={cn("h-5 w-5", action.iconClasses)}
|
||||
/>
|
||||
))}
|
||||
{action.label && (
|
||||
<span className="text-sm px-1 pt-0.5">
|
||||
{action.label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
||||
devicePSKBitCount?: number;
|
||||
value: string;
|
||||
id: string;
|
||||
variant: "default" | "invalid";
|
||||
variant: "default" | "invalid" | "dirty";
|
||||
actionButtons: ActionButton[];
|
||||
bits?: { text: string; value: string; key: string }[];
|
||||
selectChange: (event: string) => void;
|
||||
|
||||
@@ -6,14 +6,17 @@ import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
|
||||
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const cnInvalidBase = "border-2 border-red-500 dark:border-red-500";
|
||||
const cnDirtyBase = "border-2 border-sky-500 dark:border-sky-500";
|
||||
|
||||
const inputVariants = cva(
|
||||
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:bg-transparet dark:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-600",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-slate-300 dark:border-slate-500",
|
||||
invalid:
|
||||
"border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-500",
|
||||
invalid: `${cnInvalidBase} focus:ring-red-500 dark:focus:ring-red-500`,
|
||||
dirty: `${cnDirtyBase} focus:ring-sky-500 dark:focus:ring-sky-500`,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -49,6 +52,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
className,
|
||||
containerClassName,
|
||||
variant,
|
||||
disabled,
|
||||
type = "text",
|
||||
prefix,
|
||||
suffix,
|
||||
@@ -137,6 +141,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
className,
|
||||
);
|
||||
|
||||
const extrasClassName = cn([
|
||||
variant === "invalid" && `${cnInvalidBase} border-l-0`,
|
||||
variant === "dirty" && `${cnDirtyBase} border-l-0`,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex w-full items-stretch", containerClassName)}
|
||||
@@ -153,6 +162,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -161,6 +171,7 @@ 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",
|
||||
extrasClassName,
|
||||
!hasActions && "rounded-r-md",
|
||||
)}
|
||||
>
|
||||
@@ -171,7 +182,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
{hasActions && (
|
||||
<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-700",
|
||||
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-500",
|
||||
extrasClassName,
|
||||
disabled &&
|
||||
"border-slate-200 dark:border-slate-700 divide-slate-200",
|
||||
!hasSuffix && "rounded-r-md",
|
||||
"bg-white dark:bg-slate-800",
|
||||
)}
|
||||
@@ -181,7 +195,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
key={action.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 hover:rounded-md dark:hover:rounded-md",
|
||||
"inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 last:hover:rounded-r-md last:dark:hover:rounded-r-md",
|
||||
disabled && "text-slate-300 dark:text-slate-600",
|
||||
action.id === "copy-value" && isCopied &&
|
||||
"text-green-600 dark:text-green-500",
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -44,12 +44,19 @@ export const mockDeviceStore: Device = {
|
||||
unsafeRoles: false,
|
||||
refreshKeys: false,
|
||||
deleteMessages: false,
|
||||
managedMode: false,
|
||||
},
|
||||
setStatus: vi.fn(),
|
||||
setConfig: vi.fn(),
|
||||
setModuleConfig: vi.fn(),
|
||||
setWorkingConfig: vi.fn(),
|
||||
setWorkingModuleConfig: vi.fn(),
|
||||
getWorkingConfig: vi.fn(),
|
||||
getWorkingModuleConfig: vi.fn(),
|
||||
removeWorkingConfig: vi.fn(),
|
||||
removeWorkingModuleConfig: vi.fn(),
|
||||
getEffectiveConfig: vi.fn(),
|
||||
getEffectiveModuleConfig: vi.fn(),
|
||||
setHardware: vi.fn(),
|
||||
setActiveNode: vi.fn(),
|
||||
setPendingSettingsChanges: vi.fn(),
|
||||
|
||||
@@ -18,6 +18,14 @@ type NodeError = {
|
||||
node: number;
|
||||
error: string;
|
||||
};
|
||||
export type ValidConfigType = Exclude<
|
||||
Protobuf.Config.Config["payloadVariant"]["case"],
|
||||
"deviceUi" | "sessionkey" | undefined
|
||||
>;
|
||||
export type ValidModuleConfigType = Exclude<
|
||||
Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"],
|
||||
undefined
|
||||
>;
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
@@ -54,6 +62,7 @@ export interface Device {
|
||||
unsafeRoles: boolean;
|
||||
refreshKeys: boolean;
|
||||
deleteMessages: boolean;
|
||||
managedMode: boolean;
|
||||
};
|
||||
|
||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||
@@ -61,6 +70,26 @@ export interface Device {
|
||||
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
||||
setWorkingConfig: (config: Protobuf.Config.Config) => void;
|
||||
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
||||
getWorkingConfig: (
|
||||
payloadVariant: ValidConfigType,
|
||||
) =>
|
||||
| Protobuf.LocalOnly.LocalConfig[Exclude<ValidConfigType, undefined>]
|
||||
| undefined;
|
||||
getWorkingModuleConfig: (
|
||||
payloadVariant: ValidModuleConfigType,
|
||||
) =>
|
||||
| Protobuf.LocalOnly.LocalModuleConfig[
|
||||
Exclude<ValidModuleConfigType, undefined>
|
||||
]
|
||||
| undefined;
|
||||
removeWorkingConfig: (payloadVariant?: ValidConfigType) => void;
|
||||
removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void;
|
||||
getEffectiveConfig<K extends ValidConfigType>(
|
||||
payloadVariant: K,
|
||||
): Protobuf.LocalOnly.LocalConfig[K] | undefined;
|
||||
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
|
||||
payloadVariant: K,
|
||||
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
|
||||
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
|
||||
setActiveNode: (node: number) => void;
|
||||
setPendingSettingsChanges: (state: boolean) => void;
|
||||
@@ -142,6 +171,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
refreshKeys: false,
|
||||
rebootOTA: false,
|
||||
deleteMessages: false,
|
||||
managedMode: false,
|
||||
},
|
||||
pendingSettingsChanges: false,
|
||||
messageDraft: "",
|
||||
@@ -277,6 +307,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
const index = device.workingConfig.findIndex(
|
||||
(wc) => wc.payloadVariant.case === config.payloadVariant.case,
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
device.workingConfig[index] = config;
|
||||
} else {
|
||||
@@ -297,6 +328,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
wmc.payloadVariant.case ===
|
||||
moduleConfig.payloadVariant.case,
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
device.workingModuleConfig[index] = moduleConfig;
|
||||
} else {
|
||||
@@ -305,6 +337,106 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
getWorkingConfig: (payloadVariant: ValidConfigType) => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) return;
|
||||
|
||||
const workingConfig = device.workingConfig.find(
|
||||
(c) => c.payloadVariant.case === payloadVariant,
|
||||
);
|
||||
|
||||
if (
|
||||
workingConfig?.payloadVariant.case === "deviceUi" ||
|
||||
workingConfig?.payloadVariant.case === "sessionkey"
|
||||
) return;
|
||||
|
||||
return workingConfig?.payloadVariant.value;
|
||||
},
|
||||
getWorkingModuleConfig: (payloadVariant: ValidModuleConfigType) => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) return;
|
||||
|
||||
return device.workingModuleConfig.find(
|
||||
(c) => c.payloadVariant.case === payloadVariant,
|
||||
)?.payloadVariant.value;
|
||||
},
|
||||
|
||||
removeWorkingConfig: (payloadVariant?: ValidConfigType) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) return;
|
||||
|
||||
if (!payloadVariant) {
|
||||
device.workingConfig = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const index = device.workingConfig.findIndex(
|
||||
(wc: Protobuf.Config.Config) =>
|
||||
wc.payloadVariant.case === payloadVariant,
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
device.workingConfig.splice(index, 1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
removeWorkingModuleConfig: (
|
||||
payloadVariant?: ValidModuleConfigType,
|
||||
) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) return;
|
||||
|
||||
if (!payloadVariant) {
|
||||
device.workingModuleConfig = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const index = device.workingModuleConfig.findIndex(
|
||||
(wc: Protobuf.ModuleConfig.ModuleConfig) =>
|
||||
wc.payloadVariant.case === payloadVariant,
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
device.workingModuleConfig.splice(index, 1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
getEffectiveConfig<K extends ValidConfigType>(
|
||||
payloadVariant: K,
|
||||
): Protobuf.LocalOnly.LocalConfig[K] | undefined {
|
||||
if (!payloadVariant) return;
|
||||
const device = get().devices.get(id);
|
||||
if (!device) return;
|
||||
|
||||
return {
|
||||
...device.config[payloadVariant],
|
||||
...(device.workingConfig.find(
|
||||
(c) => c.payloadVariant.case === payloadVariant,
|
||||
)?.payloadVariant.value),
|
||||
};
|
||||
},
|
||||
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
|
||||
payloadVariant: K,
|
||||
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) return;
|
||||
|
||||
return {
|
||||
...device.moduleConfig[payloadVariant],
|
||||
...(device.workingModuleConfig.find(
|
||||
(c) => c.payloadVariant.case === payloadVariant,
|
||||
)?.payloadVariant.value),
|
||||
};
|
||||
},
|
||||
|
||||
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deepCompareConfig } from "./deepCompareConfig.ts";
|
||||
|
||||
describe("deepCompareConfig", () => {
|
||||
it("returns true for identical primitives", () => {
|
||||
expect(deepCompareConfig(5, 5)).toBe(true);
|
||||
expect(deepCompareConfig("foo", "foo")).toBe(true);
|
||||
expect(deepCompareConfig(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for different primitives", () => {
|
||||
expect(deepCompareConfig(5, 6)).toBe(false);
|
||||
expect(deepCompareConfig("foo", "bar")).toBe(false);
|
||||
expect(deepCompareConfig(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles nulls correctly", () => {
|
||||
expect(deepCompareConfig(null, null)).toBe(true);
|
||||
expect(deepCompareConfig(null, undefined)).toBe(false);
|
||||
expect(deepCompareConfig(null, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("allows undefined in working when allowUndefined is true", () => {
|
||||
expect(deepCompareConfig({ a: 1 }, { a: undefined }, true)).toBe(true);
|
||||
expect(deepCompareConfig([1, 2, 3], [1, undefined, 3], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects undefined in working when allowUndefined is false", () => {
|
||||
expect(deepCompareConfig({ a: 1 }, { a: undefined }, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares arrays deeply", () => {
|
||||
expect(deepCompareConfig([1, [2, 3]], [1, [2, 3]])).toBe(true);
|
||||
expect(deepCompareConfig([1, [2, 3]], [1, [2, 4]])).toBe(false);
|
||||
});
|
||||
|
||||
it("compares objects deeply", () => {
|
||||
const existing = { x: 10, y: { z: 20 } };
|
||||
const workingEqual = { x: 10, y: { z: 20 } };
|
||||
const workingDiff = { x: 10, y: { z: 21 } };
|
||||
|
||||
expect(deepCompareConfig(existing, workingEqual)).toBe(true);
|
||||
expect(deepCompareConfig(existing, workingDiff)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores $typeName key in existing", () => {
|
||||
const existing = { $typeName: "Test", a: 1 };
|
||||
const working = { a: 1 };
|
||||
expect(deepCompareConfig(existing, working)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when working has extra keys", () => {
|
||||
expect(deepCompareConfig({ a: 1 }, { a: 1, b: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it("allows working arrays to be shorter if allowUndefined is true", () => {
|
||||
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], true)).toBe(true);
|
||||
expect(deepCompareConfig([1, 2, 3, 4], [1, 2], false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function deepCompareConfig(
|
||||
a: unknown,
|
||||
b: unknown,
|
||||
allowUndefined = false,
|
||||
): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If allowUndefined is true, and one is undefined, they are considered equal. // This check is placed early to simplify subsequent logic.
|
||||
if (allowUndefined && (a === undefined || b === undefined)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof a !== typeof b || a === null || b === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length && !allowUndefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const longestLength = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < longestLength; i++) {
|
||||
if (!deepCompareConfig(a[i], b[i], allowUndefined)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isObject(a) && isObject(b)) {
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
const allKeys = new Set([...aKeys, ...bKeys]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key === "$typeName") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const aValue = a[key];
|
||||
const bValue = b[key];
|
||||
|
||||
if (!deepCompareConfig(aValue, bValue, allowUndefined)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -72,6 +72,7 @@
|
||||
"unset": "UNSET",
|
||||
"fallbackName": "Meshtastic {{last4}}",
|
||||
"formValidation": {
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"tooBig": {
|
||||
"string": "Too long, expected less than or equal to {{maximum}} characters.",
|
||||
"number": "Too big, expected a number smaller than or equal to {{maximum}}.",
|
||||
|
||||
@@ -162,5 +162,10 @@
|
||||
"choosingRightDeviceRole": "Choosing The Right Device Role",
|
||||
"deviceRoleDocumentation": "Device Role Documentation",
|
||||
"title": "Are you sure?"
|
||||
},
|
||||
"managedMode": {
|
||||
"confirmUnderstanding": "Yes, I know what I'm doing",
|
||||
"title": "Are you sure?",
|
||||
"description": "Enabling Managed Mode blocks client applications (including the web client) from writing configurations to a radio. Once enabled, radio configurations can <bold>only</bold> be changed through Remote Admin messages. This setting is not required for remote node administration."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,45 +12,77 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDevice, type ValidConfigType } from "@core/stores/deviceStore.ts";
|
||||
import { useMemo } from "react";
|
||||
import { type ComponentType, Suspense } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
|
||||
|
||||
export const DeviceConfig = () => {
|
||||
interface ConfigProps {
|
||||
// We can get rid of this exception if we import every config schema and pass the union type
|
||||
// deno-lint-ignore no-explicit-any
|
||||
onFormInit: (methods: UseFormReturn<any>) => void;
|
||||
}
|
||||
type TabItem = {
|
||||
case: ValidConfigType;
|
||||
label: string;
|
||||
element: ComponentType<ConfigProps>;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
|
||||
const { getWorkingConfig } = useDevice();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
const tabs = [
|
||||
const tabs: TabItem[] = [
|
||||
{
|
||||
case: "device",
|
||||
label: t("page.tabDevice"),
|
||||
element: Device,
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
case: "position",
|
||||
label: t("page.tabPosition"),
|
||||
element: Position,
|
||||
},
|
||||
{
|
||||
case: "power",
|
||||
label: t("page.tabPower"),
|
||||
element: Power,
|
||||
},
|
||||
{
|
||||
case: "network",
|
||||
label: t("page.tabNetwork"),
|
||||
element: Network,
|
||||
},
|
||||
{
|
||||
case: "display",
|
||||
label: t("page.tabDisplay"),
|
||||
element: Display,
|
||||
},
|
||||
{
|
||||
case: "lora",
|
||||
label: t("page.tabLora"),
|
||||
element: LoRa,
|
||||
},
|
||||
{
|
||||
case: "bluetooth",
|
||||
label: t("page.tabBluetooth"),
|
||||
element: Bluetooth,
|
||||
},
|
||||
{
|
||||
case: "security",
|
||||
label: t("page.tabSecurity"),
|
||||
element: Security,
|
||||
},
|
||||
];
|
||||
] as const;
|
||||
|
||||
const flags = useMemo(
|
||||
() => new Map(tabs.map((tab) => [tab.case, getWorkingConfig(tab.case)])),
|
||||
[tabs, getWorkingConfig],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={t("page.tabDevice")}>
|
||||
@@ -59,15 +91,27 @@ export const DeviceConfig = () => {
|
||||
<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 />
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<ConfigSuspender configCase={tab.case}>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</ConfigSuspender>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
@@ -16,60 +16,96 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useDevice,
|
||||
type ValidModuleConfigType,
|
||||
} from "@core/stores/deviceStore.ts";
|
||||
import { useMemo } from "react";
|
||||
import { type ComponentType, Suspense } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { ConfigSuspender } from "@components/PageComponents/Config/ConfigSuspender.tsx";
|
||||
|
||||
export const ModuleConfig = () => {
|
||||
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 = ({ 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 +114,27 @@ 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 />
|
||||
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
|
||||
<ConfigSuspender moduleConfigCase={tab.case}>
|
||||
<tab.element onFormInit={onFormInit} />
|
||||
</ConfigSuspender>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
+142
-45
@@ -1,27 +1,49 @@
|
||||
import { useAppStore } from "../../core/stores/appStore.ts";
|
||||
import { useAppStore } from "@core/stores/appStore.ts";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { SidebarButton } from "../../components/UI/Sidebar/SidebarButton.tsx";
|
||||
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
|
||||
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
|
||||
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
|
||||
import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
|
||||
import {
|
||||
BoxesIcon,
|
||||
RefreshCwIcon,
|
||||
SaveIcon,
|
||||
SaveOff,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
const ConfigPage = () => {
|
||||
const { workingConfig, workingModuleConfig, connection } = useDevice();
|
||||
const {
|
||||
workingConfig,
|
||||
workingModuleConfig,
|
||||
connection,
|
||||
removeWorkingConfig,
|
||||
removeWorkingModuleConfig,
|
||||
setConfig,
|
||||
setModuleConfig,
|
||||
} = useDevice();
|
||||
const { hasErrors } = useAppStore();
|
||||
|
||||
const [activeConfigSection, setActiveConfigSection] = useState<
|
||||
"device" | "module"
|
||||
>("device");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
|
||||
const { toast } = useToast();
|
||||
const isError = hasErrors();
|
||||
const { t } = useTranslation("deviceConfig");
|
||||
|
||||
const onFormInit = (methods: UseFormReturn) => {
|
||||
setFormMethods(methods);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (hasErrors()) {
|
||||
return toast({
|
||||
@@ -31,36 +53,49 @@ const ConfigPage = () => {
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (activeConfigSection === "device") {
|
||||
await Promise.all(
|
||||
workingConfig.map((config) =>
|
||||
connection?.setConfig(config).then(() =>
|
||||
toast({
|
||||
title: t("toast.saveSuccess.title"),
|
||||
description: t("toast.saveSuccess.description", {
|
||||
case: config.payloadVariant.case,
|
||||
}),
|
||||
})
|
||||
)
|
||||
),
|
||||
await Promise.all(
|
||||
workingConfig.map((newConfig) =>
|
||||
connection?.setConfig(newConfig).then(() => {
|
||||
toast({
|
||||
title: t("toast.saveSuccess.title"),
|
||||
description: t("toast.saveSuccess.description", {
|
||||
case: newConfig.payloadVariant.case,
|
||||
}),
|
||||
});
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
workingModuleConfig.map((newModuleConfig) =>
|
||||
connection?.setModuleConfig(newModuleConfig).then(() =>
|
||||
toast({
|
||||
title: t("toast.saveSuccess.title"),
|
||||
description: t("toast.saveSuccess.description", {
|
||||
case: newModuleConfig.payloadVariant.case,
|
||||
}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
await connection?.commitEditSettings().then(() => {
|
||||
if (formMethods) {
|
||||
formMethods.reset({}, {
|
||||
keepValues: true,
|
||||
});
|
||||
}
|
||||
|
||||
workingConfig.map((newConfig) => setConfig(newConfig));
|
||||
workingModuleConfig.map((newModuleConfig) =>
|
||||
setModuleConfig(newModuleConfig)
|
||||
);
|
||||
} 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 connection?.commitEditSettings();
|
||||
|
||||
removeWorkingConfig();
|
||||
removeWorkingModuleConfig();
|
||||
});
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: t("toast.configSaveError.title"),
|
||||
@@ -71,6 +106,15 @@ const ConfigPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (formMethods) {
|
||||
formMethods.reset();
|
||||
}
|
||||
|
||||
removeWorkingConfig();
|
||||
removeWorkingModuleConfig();
|
||||
};
|
||||
|
||||
const leftSidebar = useMemo(
|
||||
() => (
|
||||
<Sidebar>
|
||||
@@ -83,19 +127,79 @@ 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 &&
|
||||
Object.keys(formMethods?.formState.dirtyFields ?? {}).length > 0 ||
|
||||
workingConfig.length > 0 || workingModuleConfig.length > 0
|
||||
? "opacity-100"
|
||||
: "opacity-0"),
|
||||
[
|
||||
formMethods?.formState.isDirty,
|
||||
formMethods?.formState.dirtyFields,
|
||||
workingConfig,
|
||||
workingModuleConfig,
|
||||
],
|
||||
);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
return Object.keys(formMethods?.formState.errors ?? {}).length === 0;
|
||||
}, [formMethods?.formState.errors]);
|
||||
|
||||
const actions = useMemo(() => [
|
||||
{
|
||||
key: "unsavedChanges",
|
||||
label: t("common:formValidation.unsavedChanges"),
|
||||
onClick: () => {},
|
||||
className: cn([
|
||||
"bg-blue-500 hover:bg-blue-500 text-white hover:text-white",
|
||||
buttonOpacity,
|
||||
"transition-opacity",
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
icon: RefreshCwIcon,
|
||||
label: t("common:button.reset"),
|
||||
onClick: handleReset,
|
||||
className: cn([buttonOpacity, "transition-opacity"]),
|
||||
},
|
||||
{
|
||||
key: "save",
|
||||
icon: !isValid ? SaveOff : SaveIcon,
|
||||
isLoading: isSaving,
|
||||
disabled: isSaving ||
|
||||
!isValid ||
|
||||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
|
||||
iconClasses: !isValid ? "text-red-400 cursor-not-allowed" : "",
|
||||
onClick: handleSave,
|
||||
label: t("common:button.save"),
|
||||
},
|
||||
], [
|
||||
activeConfigSection,
|
||||
isSaving,
|
||||
isValid,
|
||||
buttonOpacity,
|
||||
workingConfig,
|
||||
workingModuleConfig,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
@@ -104,18 +208,11 @@ const ConfigPage = () => {
|
||||
label={activeConfigSection === "device"
|
||||
? t("navigation.radioConfig")
|
||||
: t("navigation.moduleConfig")}
|
||||
actions={[
|
||||
{
|
||||
key: "save",
|
||||
icon: isError ? SaveOff : SaveIcon,
|
||||
isLoading: isSaving,
|
||||
disabled: isSaving,
|
||||
iconClasses: isError ? "text-red-400 cursor-not-allowed" : "",
|
||||
onClick: handleSave,
|
||||
},
|
||||
]}
|
||||
actions={actions}
|
||||
>
|
||||
{activeConfigSection === "device" ? <DeviceConfig /> : <ModuleConfig />}
|
||||
{activeConfigSection === "device"
|
||||
? <DeviceConfig onFormInit={onFormInit} />
|
||||
: <ModuleConfig onFormInit={onFormInit} />}
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export const NetworkValidationSchema = z.object({
|
||||
wifiEnabled: z.boolean(),
|
||||
wifiSsid: z.string().max(33),
|
||||
wifiPsk: z.string().max(64),
|
||||
ntpServer: z.string().min(2).max(33),
|
||||
ntpServer: z.string().min(0).max(33),
|
||||
ethEnabled: z.boolean(),
|
||||
addressMode: AddressModeEnum,
|
||||
ipv4Config: NetworkValidationIpV4ConfigSchema,
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("RawSecuritySchema", () => {
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message === "formValidation.adminKeyRequiredWhenManaged"
|
||||
i.message === "formValidation.required.managed"
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ describe("ParsedSecuritySchema", () => {
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message === "formValidation.adminKeyRequiredWhenManaged"
|
||||
i.message === "formValidation.required.managed"
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const {
|
||||
isValidKey,
|
||||
} = makePskHelpers([32]); // 256-bit
|
||||
|
||||
const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged";
|
||||
const isManagedRequiredMsg = "formValidation.required.managed";
|
||||
|
||||
function makeSecuritySchema<KeyT>(
|
||||
keyMaker: (optional: boolean) => ZodType<KeyT>,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const MqttValidationMapReportSettingsSchema = z.object({
|
||||
publishIntervalSecs: z.number().optional(),
|
||||
positionPrecision: z.number().optional(),
|
||||
publishIntervalSecs: z.coerce.number().int(),
|
||||
positionPrecision: z.coerce.number().int(),
|
||||
});
|
||||
|
||||
export const MqttValidationSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user