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 Path,
|
||||||
type SubmitHandler,
|
type SubmitHandler,
|
||||||
useForm,
|
useForm,
|
||||||
|
type UseFormReturn,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||||
import { ZodType } from "zod/v4";
|
import { ZodType } from "zod/v4";
|
||||||
@@ -44,13 +45,18 @@ export interface GenericFormElementProps<T extends FieldValues, Y> {
|
|||||||
control: Control<T>;
|
control: Control<T>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
field: Y;
|
field: Y;
|
||||||
|
isDirty?: boolean;
|
||||||
|
invalid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DynamicFormProps<T extends FieldValues> {
|
export interface DynamicFormProps<T extends FieldValues> {
|
||||||
|
propMethods?: UseFormReturn<T, T, T>;
|
||||||
onSubmit: SubmitHandler<T>;
|
onSubmit: SubmitHandler<T>;
|
||||||
|
onFormInit?: DynamicFormFormInit<T>;
|
||||||
submitType?: "onChange" | "onSubmit";
|
submitType?: "onChange" | "onSubmit";
|
||||||
hasSubmitButton?: boolean;
|
hasSubmitButton?: boolean;
|
||||||
defaultValues?: DefaultValues<T>;
|
defaultValues?: DefaultValues<T>;
|
||||||
|
values?: T;
|
||||||
fieldGroups: {
|
fieldGroups: {
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -63,11 +69,18 @@ export interface DynamicFormProps<T extends FieldValues> {
|
|||||||
formId?: string;
|
formId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DynamicFormFormInit<T extends FieldValues> = (
|
||||||
|
methods: UseFormReturn<T, T, T>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
export function DynamicForm<T extends FieldValues>({
|
export function DynamicForm<T extends FieldValues>({
|
||||||
|
propMethods,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onFormInit,
|
||||||
submitType = "onChange",
|
submitType = "onChange",
|
||||||
hasSubmitButton,
|
hasSubmitButton,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
values,
|
||||||
fieldGroups,
|
fieldGroups,
|
||||||
validationSchema,
|
validationSchema,
|
||||||
formId,
|
formId,
|
||||||
@@ -78,17 +91,29 @@ export function DynamicForm<T extends FieldValues>({
|
|||||||
removeError,
|
removeError,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const methods = useForm<
|
let methods = propMethods;
|
||||||
T
|
if (!methods) {
|
||||||
>({
|
methods = useForm<
|
||||||
mode: "onChange",
|
T
|
||||||
defaultValues: defaultValues,
|
>({
|
||||||
resolver: validationSchema
|
mode: "onChange",
|
||||||
? createZodResolver(validationSchema)
|
defaultValues: defaultValues,
|
||||||
: undefined,
|
resolver: validationSchema
|
||||||
shouldFocusError: false,
|
? createZodResolver(validationSchema)
|
||||||
});
|
: undefined,
|
||||||
const { handleSubmit, control, getValues, formState } = methods;
|
shouldFocusError: false,
|
||||||
|
resetOptions: { keepDefaultValues: true },
|
||||||
|
values,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { handleSubmit, control, getValues, formState, getFieldState } =
|
||||||
|
methods;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!propMethods) {
|
||||||
|
onFormInit?.(methods);
|
||||||
|
}
|
||||||
|
}, [onFormInit, propMethods, methods]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const errorKeys = Object.keys(formState.errors);
|
const errorKeys = Object.keys(formState.errors);
|
||||||
@@ -155,25 +180,22 @@ export function DynamicForm<T extends FieldValues>({
|
|||||||
label={field.label}
|
label={field.label}
|
||||||
fieldName={field.name}
|
fieldName={field.name}
|
||||||
description={field.description}
|
description={field.description}
|
||||||
valid={validationSchema // keep backwards compat with not updated cfg pages
|
valid={!error}
|
||||||
? !error
|
validationText={error
|
||||||
: field.validationText === undefined ||
|
? String(
|
||||||
field.validationText === ""}
|
t([`formValidation.${error.type}`, error.message], {
|
||||||
validationText={validationSchema
|
returnObjects: false,
|
||||||
? (error
|
...error.params,
|
||||||
? String(
|
}),
|
||||||
t([`formValidation.${error.type}`, error.message], {
|
)
|
||||||
returnObjects: false,
|
: ""}
|
||||||
...error.params,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: "")
|
|
||||||
: field.validationText}
|
|
||||||
>
|
>
|
||||||
<DynamicFormField
|
<DynamicFormField
|
||||||
field={field}
|
field={field}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={isDisabled(field.disabledBy, field.disabled)}
|
disabled={isDisabled(field.disabledBy, field.disabled)}
|
||||||
|
isDirty={getFieldState(field.name).isDirty}
|
||||||
|
invalid={getFieldState(field.name).invalid}
|
||||||
/>
|
/>
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,19 +31,29 @@ export interface DynamicFormFieldProps<T extends FieldValues> {
|
|||||||
field: FieldProps<T>;
|
field: FieldProps<T>;
|
||||||
control: Control<T>;
|
control: Control<T>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
isDirty?: boolean;
|
||||||
|
invalid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DynamicFormField<T extends FieldValues>({
|
export function DynamicFormField<T extends FieldValues>({
|
||||||
field,
|
field,
|
||||||
control,
|
control,
|
||||||
disabled,
|
disabled,
|
||||||
|
isDirty,
|
||||||
|
invalid,
|
||||||
}: DynamicFormFieldProps<T>) {
|
}: DynamicFormFieldProps<T>) {
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case "text":
|
case "text":
|
||||||
case "password":
|
case "password":
|
||||||
case "number":
|
case "number":
|
||||||
return (
|
return (
|
||||||
<GenericInput field={field} control={control} disabled={disabled} />
|
<GenericInput
|
||||||
|
field={field}
|
||||||
|
control={control}
|
||||||
|
disabled={disabled}
|
||||||
|
isDirty={isDirty}
|
||||||
|
invalid={invalid}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "toggle":
|
case "toggle":
|
||||||
@@ -52,6 +62,8 @@ export function DynamicFormField<T extends FieldValues>({
|
|||||||
field={field}
|
field={field}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
isDirty={isDirty}
|
||||||
|
invalid={invalid}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "select":
|
case "select":
|
||||||
@@ -60,6 +72,8 @@ export function DynamicFormField<T extends FieldValues>({
|
|||||||
field={field}
|
field={field}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
isDirty={isDirty}
|
||||||
|
invalid={invalid}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "passwordGenerator":
|
case "passwordGenerator":
|
||||||
@@ -68,11 +82,19 @@ export function DynamicFormField<T extends FieldValues>({
|
|||||||
field={field}
|
field={field}
|
||||||
control={control}
|
control={control}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
isDirty={isDirty}
|
||||||
|
invalid={invalid}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "multiSelect":
|
case "multiSelect":
|
||||||
return (
|
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,
|
control,
|
||||||
disabled,
|
disabled,
|
||||||
field,
|
field,
|
||||||
|
isDirty,
|
||||||
|
invalid,
|
||||||
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
||||||
const { fieldLength, ...restProperties } = field.properties || {};
|
const { fieldLength, ...restProperties } = field.properties || {};
|
||||||
const [currentLength, setCurrentLength] = useState<number>(
|
const [currentLength, setCurrentLength] = useState<number>(
|
||||||
@@ -78,6 +80,7 @@ export function GenericInput<T extends FieldValues>({
|
|||||||
className={field.properties?.className}
|
className={field.properties?.className}
|
||||||
{...restProperties}
|
{...restProperties}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{fieldLength?.showCharacterCount && fieldLength?.max && (
|
{fieldLength?.showCharacterCount && fieldLength?.max && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { FieldValues } from "react-hook-form";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
|
import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
|
||||||
import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx";
|
import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "multiSelect";
|
type: "multiSelect";
|
||||||
@@ -23,9 +24,11 @@ export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
|
|
||||||
export function MultiSelectInput<T extends FieldValues>({
|
export function MultiSelectInput<T extends FieldValues>({
|
||||||
field,
|
field,
|
||||||
|
isDirty,
|
||||||
|
invalid,
|
||||||
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
|
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
const { enumValue, ...remainingProperties } = field.properties;
|
const { enumValue, className, ...remainingProperties } = field.properties;
|
||||||
|
|
||||||
const isNewConfigStructure =
|
const isNewConfigStructure =
|
||||||
typeof Object.values(enumValue)[0] === "object" &&
|
typeof Object.values(enumValue)[0] === "object" &&
|
||||||
@@ -48,7 +51,15 @@ export function MultiSelectInput<T extends FieldValues>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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) => {
|
{optionsToRender.map((option) => {
|
||||||
return (
|
return (
|
||||||
<MultiSelectItem
|
<MultiSelectItem
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export function PasswordGenerator<T extends FieldValues>({
|
|||||||
control,
|
control,
|
||||||
field,
|
field,
|
||||||
disabled,
|
disabled,
|
||||||
|
isDirty,
|
||||||
|
invalid,
|
||||||
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
||||||
const { isVisible } = usePasswordVisibilityToggle();
|
const { isVisible } = usePasswordVisibilityToggle();
|
||||||
const { trigger } = useFormContext();
|
const { trigger } = useFormContext();
|
||||||
@@ -42,7 +44,11 @@ export function PasswordGenerator<T extends FieldValues>({
|
|||||||
<Controller
|
<Controller
|
||||||
name={field.name}
|
name={field.name}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange, ...rest } }) => (
|
render={(
|
||||||
|
{
|
||||||
|
field: { value, onChange, ...rest },
|
||||||
|
},
|
||||||
|
) => (
|
||||||
<Generator
|
<Generator
|
||||||
type={field.hide && !isVisible ? "password" : "text"}
|
type={field.hide && !isVisible ? "password" : "text"}
|
||||||
id={field.id}
|
id={field.id}
|
||||||
@@ -54,7 +60,7 @@ export function PasswordGenerator<T extends FieldValues>({
|
|||||||
}}
|
}}
|
||||||
selectChange={field.selectChange ?? (() => {})}
|
selectChange={field.selectChange ?? (() => {})}
|
||||||
value={value}
|
value={value}
|
||||||
variant={field.validationText ? "invalid" : "default"}
|
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
|
||||||
actionButtons={field.actionButtons}
|
actionButtons={field.actionButtons}
|
||||||
showPasswordToggle={field.showPasswordToggle}
|
showPasswordToggle={field.showPasswordToggle}
|
||||||
showCopyButton={field.showCopyButton}
|
showCopyButton={field.showCopyButton}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@components/UI/Select.tsx";
|
} from "@components/UI/Select.tsx";
|
||||||
import { type FieldValues, useController } from "react-hook-form";
|
import { type FieldValues, useController } from "react-hook-form";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "select";
|
type: "select";
|
||||||
@@ -38,6 +39,8 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
control,
|
control,
|
||||||
disabled,
|
disabled,
|
||||||
field,
|
field,
|
||||||
|
isDirty,
|
||||||
|
invalid,
|
||||||
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
|
}: GenericFormElementProps<T, SelectFieldProps<T>>) {
|
||||||
const {
|
const {
|
||||||
field: { value, onChange, ref, onBlur, ...rest },
|
field: { value, onChange, ref, onBlur, ...rest },
|
||||||
@@ -46,8 +49,13 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
control,
|
control,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { enumValue, formatEnumName, defaultValue, ...remainingProperties } =
|
const {
|
||||||
field.properties;
|
enumValue,
|
||||||
|
formatEnumName,
|
||||||
|
defaultValue,
|
||||||
|
className,
|
||||||
|
...remainingProperties
|
||||||
|
} = field.properties;
|
||||||
const valueToKeyMap: Record<string, string> = {};
|
const valueToKeyMap: Record<string, string> = {};
|
||||||
const optionsEnumValues: [string, number][] = [];
|
const optionsEnumValues: [string, number][] = [];
|
||||||
|
|
||||||
@@ -82,6 +90,13 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
id={field.name}
|
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}
|
ref={ref}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
{...remainingProperties}
|
{...remainingProperties}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
} from "@components/Form/DynamicForm.tsx";
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import { Switch } from "@components/UI/Switch.tsx";
|
import { Switch } from "@components/UI/Switch.tsx";
|
||||||
import { Controller, type FieldValues } from "react-hook-form";
|
import { Controller, type FieldValues } from "react-hook-form";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
|
||||||
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface ToggleFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "toggle";
|
type: "toggle";
|
||||||
@@ -14,12 +15,16 @@ export function ToggleInput<T extends FieldValues>({
|
|||||||
control,
|
control,
|
||||||
disabled,
|
disabled,
|
||||||
field,
|
field,
|
||||||
|
isDirty,
|
||||||
|
invalid,
|
||||||
}: GenericFormElementProps<T, ToggleFieldProps<T>>) {
|
}: GenericFormElementProps<T, ToggleFieldProps<T>>) {
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
name={field.name}
|
name={field.name}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange, ...rest } }) => (
|
render={(
|
||||||
|
{ field: { value, onChange, ...rest } },
|
||||||
|
) => (
|
||||||
<Switch
|
<Switch
|
||||||
checked={value}
|
checked={value}
|
||||||
onCheckedChange={(v) => {
|
onCheckedChange={(v) => {
|
||||||
@@ -29,6 +34,15 @@ export function ToggleInput<T extends FieldValues>({
|
|||||||
id={field.name}
|
id={field.name}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...field.properties}
|
{...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}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,16 +3,29 @@ import {
|
|||||||
BluetoothValidationSchema,
|
BluetoothValidationSchema,
|
||||||
} from "@app/validation/config/bluetooth.ts";
|
} from "@app/validation/config/bluetooth.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Bluetooth = () => {
|
interface BluetoothConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<BluetoothValidation>;
|
||||||
|
}
|
||||||
|
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
|
||||||
|
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const onSubmit = (data: BluetoothValidation) => {
|
const onSubmit = (data: BluetoothValidation) => {
|
||||||
|
if (deepCompareConfig(config.bluetooth, data, true)) {
|
||||||
|
removeWorkingConfig("bluetooth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +39,11 @@ export const Bluetooth = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<BluetoothValidation>
|
<DynamicForm<BluetoothValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={BluetoothValidationSchema}
|
validationSchema={BluetoothValidationSchema}
|
||||||
formId="Config_BluetoothConfig"
|
formId="Config_BluetoothConfig"
|
||||||
defaultValues={config.bluetooth}
|
defaultValues={config.bluetooth}
|
||||||
|
values={getEffectiveConfig("bluetooth")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("bluetooth.title"),
|
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,
|
DeviceValidationSchema,
|
||||||
} from "@app/validation/config/device.ts";
|
} from "@app/validation/config/device.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Device = () => {
|
interface DeviceConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<DeviceValidation>;
|
||||||
|
}
|
||||||
|
export const Device = ({ onFormInit }: DeviceConfigProps) => {
|
||||||
|
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
const { validateRoleSelection } = useUnsafeRolesDialog();
|
const { validateRoleSelection } = useUnsafeRolesDialog();
|
||||||
|
|
||||||
const onSubmit = (data: DeviceValidation) => {
|
const onSubmit = (data: DeviceValidation) => {
|
||||||
|
if (deepCompareConfig(config.device, data, true)) {
|
||||||
|
removeWorkingConfig("device");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -24,12 +37,15 @@ export const Device = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicForm<DeviceValidation>
|
<DynamicForm<DeviceValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={DeviceValidationSchema}
|
validationSchema={DeviceValidationSchema}
|
||||||
formId="Config_DeviceConfig"
|
formId="Config_DeviceConfig"
|
||||||
defaultValues={config.device}
|
defaultValues={config.device}
|
||||||
|
values={getEffectiveConfig("device")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("device.title"),
|
label: t("device.title"),
|
||||||
@@ -97,7 +113,8 @@ export const Device = () => {
|
|||||||
properties: {
|
properties: {
|
||||||
fieldLength: {
|
fieldLength: {
|
||||||
max: 64,
|
max: 64,
|
||||||
currentValueLength: config.device?.tzdef?.length,
|
currentValueLength: getEffectiveConfig("device")?.tzdef
|
||||||
|
?.length,
|
||||||
showCharacterCount: true,
|
showCharacterCount: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,16 +3,29 @@ import {
|
|||||||
DisplayValidationSchema,
|
DisplayValidationSchema,
|
||||||
} from "@app/validation/config/display.ts";
|
} from "@app/validation/config/display.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Display = () => {
|
interface DisplayConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<DisplayValidation>;
|
||||||
|
}
|
||||||
|
export const Display = ({ onFormInit }: DisplayConfigProps) => {
|
||||||
|
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const onSubmit = (data: DisplayValidation) => {
|
const onSubmit = (data: DisplayValidation) => {
|
||||||
|
if (deepCompareConfig(config.display, data, true)) {
|
||||||
|
removeWorkingConfig("display");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +39,11 @@ export const Display = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<DisplayValidation>
|
<DynamicForm<DisplayValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={DisplayValidationSchema}
|
validationSchema={DisplayValidationSchema}
|
||||||
formId="Config_DisplayConfig"
|
formId="Config_DisplayConfig"
|
||||||
defaultValues={config.display}
|
defaultValues={config.display}
|
||||||
|
values={getEffectiveConfig("display")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("display.title"),
|
label: t("display.title"),
|
||||||
|
|||||||
@@ -3,16 +3,29 @@ import {
|
|||||||
LoRaValidationSchema,
|
LoRaValidationSchema,
|
||||||
} from "@app/validation/config/lora.ts";
|
} from "@app/validation/config/lora.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const LoRa = () => {
|
interface LoRaConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<LoRaValidation>;
|
||||||
|
}
|
||||||
|
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
|
||||||
|
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const onSubmit = (data: LoRaValidation) => {
|
const onSubmit = (data: LoRaValidation) => {
|
||||||
|
if (deepCompareConfig(config.lora, data, true)) {
|
||||||
|
removeWorkingConfig("lora");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +39,11 @@ export const LoRa = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<LoRaValidation>
|
<DynamicForm<LoRaValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={LoRaValidationSchema}
|
validationSchema={LoRaValidationSchema}
|
||||||
formId="Config_LoRaConfig"
|
formId="Config_LoRaConfig"
|
||||||
defaultValues={config.lora}
|
defaultValues={config.lora}
|
||||||
|
values={getEffectiveConfig("lora")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("lora.title"),
|
label: t("lora.title"),
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import {
|
|||||||
NetworkValidationSchema,
|
NetworkValidationSchema,
|
||||||
} from "@app/validation/config/network.ts";
|
} from "@app/validation/config/network.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import {
|
import {
|
||||||
convertIntToIpAddress,
|
convertIntToIpAddress,
|
||||||
@@ -11,35 +14,50 @@ import {
|
|||||||
} from "@core/utils/ip.ts";
|
} from "@core/utils/ip.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Network = () => {
|
interface NetworkConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<NetworkValidation>;
|
||||||
|
}
|
||||||
|
export const Network = ({ onFormInit }: NetworkConfigProps) => {
|
||||||
|
const { config, setWorkingConfig, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
|
const networkConfig = getEffectiveConfig("network");
|
||||||
|
|
||||||
const onSubmit = (data: NetworkValidation) => {
|
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(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
case: "network",
|
case: "network",
|
||||||
value: {
|
value: 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 ?? ""),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicForm<NetworkValidation>
|
<DynamicForm<NetworkValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={NetworkValidationSchema}
|
validationSchema={NetworkValidationSchema}
|
||||||
formId="Config_NetworkConfig"
|
formId="Config_NetworkConfig"
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
@@ -57,6 +75,21 @@ export const Network = () => {
|
|||||||
enabledProtocols: config.network?.enabledProtocols ??
|
enabledProtocols: config.network?.enabledProtocols ??
|
||||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
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={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("network.title"),
|
label: t("network.title"),
|
||||||
|
|||||||
@@ -7,20 +7,33 @@ import {
|
|||||||
PositionValidationSchema,
|
PositionValidationSchema,
|
||||||
} from "@app/validation/config/position.ts";
|
} from "@app/validation/config/position.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Position = () => {
|
interface PositionConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<PositionValidation>;
|
||||||
|
}
|
||||||
|
export const Position = ({ onFormInit }: PositionConfigProps) => {
|
||||||
|
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||||
config?.position?.positionFlags ?? 0,
|
getEffectiveConfig("position")?.positionFlags ?? 0,
|
||||||
);
|
);
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const onSubmit = (data: PositionValidation) => {
|
const onSubmit = (data: PositionValidation) => {
|
||||||
|
if (deepCompareConfig(config.position, data, true)) {
|
||||||
|
removeWorkingConfig("position");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return setWorkingConfig(
|
return setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -44,9 +57,11 @@ export const Position = () => {
|
|||||||
data.positionFlags = flagsValue;
|
data.positionFlags = flagsValue;
|
||||||
return onSubmit(data);
|
return onSubmit(data);
|
||||||
}}
|
}}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={PositionValidationSchema}
|
validationSchema={PositionValidationSchema}
|
||||||
formId="Config_PositionConfig"
|
formId="Config_PositionConfig"
|
||||||
defaultValues={config.position}
|
defaultValues={config.position}
|
||||||
|
values={getEffectiveConfig("position")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("position.title"),
|
label: t("position.title"),
|
||||||
|
|||||||
@@ -3,16 +3,29 @@ import {
|
|||||||
PowerValidationSchema,
|
PowerValidationSchema,
|
||||||
} from "@app/validation/config/power.ts";
|
} from "@app/validation/config/power.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Power = () => {
|
interface PowerConfigProps {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<PowerValidation>;
|
||||||
|
}
|
||||||
|
export const Power = ({ onFormInit }: PowerConfigProps) => {
|
||||||
|
const { setWorkingConfig, config, getEffectiveConfig, removeWorkingConfig } =
|
||||||
|
useDevice();
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const onSubmit = (data: PowerValidation) => {
|
const onSubmit = (data: PowerValidation) => {
|
||||||
|
if (deepCompareConfig(config.power, data, true)) {
|
||||||
|
removeWorkingConfig("power");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +39,11 @@ export const Power = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<PowerValidation>
|
<DynamicForm<PowerValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={PowerValidationSchema}
|
validationSchema={PowerValidationSchema}
|
||||||
formId="Config_PowerConfig"
|
formId="Config_PowerConfig"
|
||||||
defaultValues={config.power}
|
defaultValues={config.power}
|
||||||
|
values={getEffectiveConfig("power")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("power.powerConfigSettings.label"),
|
label: t("power.powerConfigSettings.label"),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
|
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 { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts";
|
||||||
import {
|
import {
|
||||||
@@ -7,37 +11,82 @@ import {
|
|||||||
type RawSecurity,
|
type RawSecurity,
|
||||||
RawSecuritySchema,
|
RawSecuritySchema,
|
||||||
} from "@app/validation/config/security.ts";
|
} from "@app/validation/config/security.ts";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { fromByteArray, toByteArray } from "base64-js";
|
import { fromByteArray, toByteArray } from "base64-js";
|
||||||
import { useTranslation } from "react-i18next";
|
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 = {
|
interface SecurityConfigProps {
|
||||||
publicKey: string;
|
onFormInit: DynamicFormFormInit<RawSecurity>;
|
||||||
privateKey: string;
|
}
|
||||||
privateKeyDialogOpen: boolean;
|
export const Security = ({ onFormInit }: SecurityConfigProps) => {
|
||||||
};
|
const {
|
||||||
|
config,
|
||||||
|
setWorkingConfig,
|
||||||
|
setDialogOpen,
|
||||||
|
getEffectiveConfig,
|
||||||
|
removeWorkingConfig,
|
||||||
|
} = useDevice();
|
||||||
|
|
||||||
export const Security = () => {
|
|
||||||
const { config, setWorkingConfig, setDialogOpen } = useDevice();
|
|
||||||
const { removeError } = useAppStore();
|
const { removeError } = useAppStore();
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const [keyState, setKeyState] = useState<KeyState>(() => ({
|
const securityConfig = getEffectiveConfig("security");
|
||||||
publicKey: fromByteArray(config?.security?.publicKey ?? new Uint8Array(0)),
|
const defaultValues = {
|
||||||
privateKey: fromByteArray(
|
...securityConfig,
|
||||||
config?.security?.privateKey ?? new Uint8Array(0),
|
...{
|
||||||
),
|
privateKey: fromByteArray(
|
||||||
privateKeyDialogOpen: false,
|
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) => {
|
const onSubmit = (data: RawSecurity) => {
|
||||||
|
if (!formState.isReady) return;
|
||||||
|
|
||||||
const payload: ParsedSecurity = {
|
const payload: ParsedSecurity = {
|
||||||
...data,
|
...data,
|
||||||
privateKey: toByteArray(keyState.privateKey),
|
privateKey: toByteArray(data.privateKey),
|
||||||
publicKey: toByteArray(keyState.publicKey),
|
publicKey: toByteArray(data.publicKey),
|
||||||
adminKey: [
|
adminKey: [
|
||||||
toByteArray(data.adminKey.at(0) ?? ""),
|
toByteArray(data.adminKey.at(0) ?? ""),
|
||||||
toByteArray(data.adminKey.at(1) ?? ""),
|
toByteArray(data.adminKey.at(1) ?? ""),
|
||||||
@@ -45,6 +94,11 @@ export const Security = () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (deepCompareConfig(config.security, payload, true)) {
|
||||||
|
removeWorkingConfig("security");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -54,18 +108,10 @@ export const Security = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pkiRegenerate = () => {
|
const pkiRegenerate = () => {
|
||||||
const privateKey = getX25519PrivateKey();
|
const privateKey = getX25519PrivateKey();
|
||||||
|
|
||||||
updatePublicKey(fromByteArray(privateKey));
|
updatePublicKey(fromByteArray(privateKey));
|
||||||
|
|
||||||
setKeyState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
privateKey: fromByteArray(privateKey),
|
|
||||||
privateKeyDialogOpen: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
removeError("privateKey");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePublicKey = (privateKey: string) => {
|
const updatePublicKey = (privateKey: string) => {
|
||||||
@@ -73,18 +119,14 @@ export const Security = () => {
|
|||||||
const publicKey = fromByteArray(
|
const publicKey = fromByteArray(
|
||||||
getX25519PublicKey(toByteArray(privateKey)),
|
getX25519PublicKey(toByteArray(privateKey)),
|
||||||
);
|
);
|
||||||
setKeyState((prev) => ({
|
setValue("privateKey", privateKey);
|
||||||
...prev,
|
setValue("publicKey", publicKey);
|
||||||
privateKey: privateKey,
|
|
||||||
publicKey: publicKey,
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
removeError("privateKey");
|
||||||
removeError("publicKey");
|
removeError("publicKey");
|
||||||
|
setPrivateKeyDialogOpen(false);
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
setKeyState((prev) => ({
|
setValue("privateKey", privateKey);
|
||||||
...prev,
|
|
||||||
privateKey: privateKey,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,31 +141,9 @@ export const Security = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicForm<RawSecurity>
|
<DynamicForm<RawSecurity>
|
||||||
|
propMethods={formMethods}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
validationSchema={RawSecuritySchema}
|
|
||||||
formId="Config_SecurityConfig"
|
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={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("security.title"),
|
label: t("security.title"),
|
||||||
@@ -144,11 +164,7 @@ export const Security = () => {
|
|||||||
actionButtons: [
|
actionButtons: [
|
||||||
{
|
{
|
||||||
text: t("button.generate"),
|
text: t("button.generate"),
|
||||||
onClick: () =>
|
onClick: () => setPrivateKeyDialogOpen(true),
|
||||||
setKeyState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
privateKeyDialogOpen: true,
|
|
||||||
})),
|
|
||||||
variant: "success",
|
variant: "success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -160,8 +176,6 @@ export const Security = () => {
|
|||||||
properties: {
|
properties: {
|
||||||
showCopyButton: true,
|
showCopyButton: true,
|
||||||
showPasswordToggle: true,
|
showPasswordToggle: true,
|
||||||
|
|
||||||
value: keyState.privateKey,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -172,7 +186,6 @@ export const Security = () => {
|
|||||||
description: t("security.publicKey.description"),
|
description: t("security.publicKey.description"),
|
||||||
properties: {
|
properties: {
|
||||||
showCopyButton: true,
|
showCopyButton: true,
|
||||||
value: keyState.publicKey,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -240,6 +253,13 @@ export const Security = () => {
|
|||||||
name: "isManaged",
|
name: "isManaged",
|
||||||
label: t("security.managed.label"),
|
label: t("security.managed.label"),
|
||||||
description: t("security.managed.description"),
|
description: t("security.managed.description"),
|
||||||
|
inputChange: (checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setManagedModeDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue("isManaged", false);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
@@ -275,14 +295,19 @@ export const Security = () => {
|
|||||||
title: t("pkiRegenerate.title"),
|
title: t("pkiRegenerate.title"),
|
||||||
description: t("pkiRegenerate.description"),
|
description: t("pkiRegenerate.description"),
|
||||||
}}
|
}}
|
||||||
open={keyState.privateKeyDialogOpen}
|
open={privateKeyDialogOpen}
|
||||||
onOpenChange={() =>
|
onOpenChange={() => setPrivateKeyDialogOpen((prev) => !prev)}
|
||||||
setKeyState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
privateKeyDialogOpen: false,
|
|
||||||
}))}
|
|
||||||
onSubmit={pkiRegenerate}
|
onSubmit={pkiRegenerate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ManagedModeDialog
|
||||||
|
open={managedModeDialogOpen}
|
||||||
|
onOpenChange={() => setManagedModeDialogOpen((prev) => !prev)}
|
||||||
|
onSubmit={() => {
|
||||||
|
setValue("isManaged", true);
|
||||||
|
setManagedModeDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,15 +4,35 @@ import {
|
|||||||
AmbientLightingValidationSchema,
|
AmbientLightingValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/ambientLighting.ts";
|
} from "@app/validation/moduleConfig/ambientLighting.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const AmbientLighting = () => {
|
interface AmbientLightingModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<AmbientLightingValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AmbientLighting = (
|
||||||
|
{ onFormInit }: AmbientLightingModuleConfigProps,
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: AmbientLightingValidation) => {
|
const onSubmit = (data: AmbientLightingValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.ambientLighting, data, true)) {
|
||||||
|
removeWorkingModuleConfig("ambientLighting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +46,11 @@ export const AmbientLighting = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<AmbientLightingValidation>
|
<DynamicForm<AmbientLightingValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={AmbientLightingValidationSchema}
|
validationSchema={AmbientLightingValidationSchema}
|
||||||
formId="ModuleConfig_AmbientLightingConfig"
|
formId="ModuleConfig_AmbientLightingConfig"
|
||||||
defaultValues={moduleConfig.ambientLighting}
|
defaultValues={moduleConfig.ambientLighting}
|
||||||
|
values={getEffectiveModuleConfig("ambientLighting")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("ambientLighting.title"),
|
label: t("ambientLighting.title"),
|
||||||
|
|||||||
@@ -3,16 +3,34 @@ import {
|
|||||||
AudioValidationSchema,
|
AudioValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/audio.ts";
|
} from "@app/validation/moduleConfig/audio.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Audio = () => {
|
interface AudioModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<AudioValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Audio = ({ onFormInit }: AudioModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: AudioValidation) => {
|
const onSubmit = (data: AudioValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.audio, data, true)) {
|
||||||
|
removeWorkingModuleConfig("audio");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +44,11 @@ export const Audio = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<AudioValidation>
|
<DynamicForm<AudioValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={AudioValidationSchema}
|
validationSchema={AudioValidationSchema}
|
||||||
formId="ModuleConfig_AudioConfig"
|
formId="ModuleConfig_AudioConfig"
|
||||||
defaultValues={moduleConfig.audio}
|
defaultValues={moduleConfig.audio}
|
||||||
|
values={getEffectiveModuleConfig("audio")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("audio.title"),
|
label: t("audio.title"),
|
||||||
|
|||||||
@@ -3,16 +3,36 @@ import {
|
|||||||
CannedMessageValidationSchema,
|
CannedMessageValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/cannedMessage.ts";
|
} from "@app/validation/moduleConfig/cannedMessage.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const CannedMessage = () => {
|
interface CannedMessageModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<CannedMessageValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CannedMessage = (
|
||||||
|
{ onFormInit }: CannedMessageModuleConfigProps,
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: CannedMessageValidation) => {
|
const onSubmit = (data: CannedMessageValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.cannedMessage, data, true)) {
|
||||||
|
removeWorkingModuleConfig("cannedMessage");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +46,11 @@ export const CannedMessage = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<CannedMessageValidation>
|
<DynamicForm<CannedMessageValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={CannedMessageValidationSchema}
|
validationSchema={CannedMessageValidationSchema}
|
||||||
formId="ModuleConfig_CannedMessageConfig"
|
formId="ModuleConfig_CannedMessageConfig"
|
||||||
defaultValues={moduleConfig.cannedMessage}
|
defaultValues={moduleConfig.cannedMessage}
|
||||||
|
values={getEffectiveModuleConfig("cannedMessage")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("cannedMessage.title"),
|
label: t("cannedMessage.title"),
|
||||||
|
|||||||
@@ -4,15 +4,35 @@ import {
|
|||||||
DetectionSensorValidationSchema,
|
DetectionSensorValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/detectionSensor.ts";
|
} from "@app/validation/moduleConfig/detectionSensor.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const DetectionSensor = () => {
|
interface DetectionSensorModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<DetectionSensorValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetectionSensor = (
|
||||||
|
{ onFormInit }: DetectionSensorModuleConfigProps,
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: DetectionSensorValidation) => {
|
const onSubmit = (data: DetectionSensorValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.detectionSensor, data, true)) {
|
||||||
|
removeWorkingModuleConfig("detectionSensor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +46,11 @@ export const DetectionSensor = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<DetectionSensorValidation>
|
<DynamicForm<DetectionSensorValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={DetectionSensorValidationSchema}
|
validationSchema={DetectionSensorValidationSchema}
|
||||||
formId="ModuleConfig_DetectionSensorConfig"
|
formId="ModuleConfig_DetectionSensorConfig"
|
||||||
defaultValues={moduleConfig.detectionSensor}
|
defaultValues={moduleConfig.detectionSensor}
|
||||||
|
values={getEffectiveModuleConfig("detectionSensor")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("detectionSensor.title"),
|
label: t("detectionSensor.title"),
|
||||||
|
|||||||
@@ -3,16 +3,36 @@ import {
|
|||||||
ExternalNotificationValidationSchema,
|
ExternalNotificationValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/externalNotification.ts";
|
} from "@app/validation/moduleConfig/externalNotification.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const ExternalNotification = () => {
|
interface ExternalNotificationModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<ExternalNotificationValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExternalNotification = (
|
||||||
|
{ onFormInit }: ExternalNotificationModuleConfigProps,
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: ExternalNotificationValidation) => {
|
const onSubmit = (data: ExternalNotificationValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.externalNotification, data, true)) {
|
||||||
|
removeWorkingModuleConfig("externalNotification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +46,11 @@ export const ExternalNotification = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<ExternalNotificationValidation>
|
<DynamicForm<ExternalNotificationValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={ExternalNotificationValidationSchema}
|
validationSchema={ExternalNotificationValidationSchema}
|
||||||
formId="ModuleConfig_ExternalNotificationConfig"
|
formId="ModuleConfig_ExternalNotificationConfig"
|
||||||
defaultValues={moduleConfig.externalNotification}
|
defaultValues={moduleConfig.externalNotification}
|
||||||
|
values={getEffectiveModuleConfig("externalNotification")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("externalNotification.title"),
|
label: t("externalNotification.title"),
|
||||||
|
|||||||
@@ -4,37 +4,72 @@ import {
|
|||||||
MqttValidationSchema,
|
MqttValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/mqtt.ts";
|
} from "@app/validation/moduleConfig/mqtt.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const MQTT = () => {
|
interface MqttModuleConfigProps {
|
||||||
const { config, moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<MqttValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MQTT = ({ onFormInit }: MqttModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: MqttValidation) => {
|
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(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
case: "mqtt",
|
case: "mqtt",
|
||||||
value: {
|
value: payload,
|
||||||
...data,
|
|
||||||
mapReportSettings: create(
|
|
||||||
Protobuf.ModuleConfig.ModuleConfig_MapReportSettingsSchema,
|
|
||||||
data.mapReportSettings,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const populateDefaultValues = (
|
||||||
|
cfg: Protobuf.ModuleConfig.ModuleConfig_MQTTConfig | undefined,
|
||||||
|
) => {
|
||||||
|
return cfg
|
||||||
|
? {
|
||||||
|
...cfg,
|
||||||
|
mapReportSettings: cfg.mapReportSettings ??
|
||||||
|
{ publishIntervalSecs: 0, positionPrecision: 10 },
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicForm<MqttValidation>
|
<DynamicForm<MqttValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={MqttValidationSchema}
|
validationSchema={MqttValidationSchema}
|
||||||
formId="ModuleConfig_MqttConfig"
|
formId="ModuleConfig_MqttConfig"
|
||||||
defaultValues={moduleConfig.mqtt}
|
defaultValues={populateDefaultValues(moduleConfig.mqtt)}
|
||||||
|
values={populateDefaultValues(getEffectiveModuleConfig("mqtt"))}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("mqtt.title"),
|
label: t("mqtt.title"),
|
||||||
|
|||||||
@@ -4,15 +4,33 @@ import {
|
|||||||
NeighborInfoValidationSchema,
|
NeighborInfoValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/neighborInfo.ts";
|
} from "@app/validation/moduleConfig/neighborInfo.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const NeighborInfo = () => {
|
interface NeighborInfoModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<NeighborInfoValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NeighborInfo = ({ onFormInit }: NeighborInfoModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: NeighborInfoValidation) => {
|
const onSubmit = (data: NeighborInfoValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.neighborInfo, data, true)) {
|
||||||
|
removeWorkingModuleConfig("neighborInfo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +44,11 @@ export const NeighborInfo = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<NeighborInfoValidation>
|
<DynamicForm<NeighborInfoValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={NeighborInfoValidationSchema}
|
validationSchema={NeighborInfoValidationSchema}
|
||||||
formId="ModuleConfig_NeighborInfoConfig"
|
formId="ModuleConfig_NeighborInfoConfig"
|
||||||
defaultValues={moduleConfig.neighborInfo}
|
defaultValues={moduleConfig.neighborInfo}
|
||||||
|
values={getEffectiveModuleConfig("neighborInfo")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("neighborInfo.title"),
|
label: t("neighborInfo.title"),
|
||||||
|
|||||||
@@ -3,16 +3,34 @@ import {
|
|||||||
PaxcounterValidationSchema,
|
PaxcounterValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/paxcounter.ts";
|
} from "@app/validation/moduleConfig/paxcounter.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Paxcounter = () => {
|
interface PaxcounterModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<PaxcounterValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Paxcounter = ({ onFormInit }: PaxcounterModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: PaxcounterValidation) => {
|
const onSubmit = (data: PaxcounterValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.paxcounter, data, true)) {
|
||||||
|
removeWorkingModuleConfig("paxcounter");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +44,11 @@ export const Paxcounter = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<PaxcounterValidation>
|
<DynamicForm<PaxcounterValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={PaxcounterValidationSchema}
|
validationSchema={PaxcounterValidationSchema}
|
||||||
formId="ModuleConfig_PaxcounterConfig"
|
formId="ModuleConfig_PaxcounterConfig"
|
||||||
defaultValues={moduleConfig.paxcounter}
|
defaultValues={moduleConfig.paxcounter}
|
||||||
|
values={getEffectiveModuleConfig("paxcounter")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("paxcounter.title"),
|
label: t("paxcounter.title"),
|
||||||
|
|||||||
@@ -3,16 +3,34 @@ import {
|
|||||||
RangeTestValidationSchema,
|
RangeTestValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/rangeTest.ts";
|
} from "@app/validation/moduleConfig/rangeTest.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const RangeTest = () => {
|
interface RangeTestModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<RangeTestValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RangeTest = ({ onFormInit }: RangeTestModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: RangeTestValidation) => {
|
const onSubmit = (data: RangeTestValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.rangeTest, data, true)) {
|
||||||
|
removeWorkingModuleConfig("rangeTest");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +44,11 @@ export const RangeTest = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<RangeTestValidation>
|
<DynamicForm<RangeTestValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={RangeTestValidationSchema}
|
validationSchema={RangeTestValidationSchema}
|
||||||
formId="ModuleConfig_RangeTestConfig"
|
formId="ModuleConfig_RangeTestConfig"
|
||||||
defaultValues={moduleConfig.rangeTest}
|
defaultValues={moduleConfig.rangeTest}
|
||||||
|
values={getEffectiveModuleConfig("rangeTest")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("rangeTest.title"),
|
label: t("rangeTest.title"),
|
||||||
|
|||||||
@@ -3,16 +3,34 @@ import {
|
|||||||
SerialValidationSchema,
|
SerialValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/serial.ts";
|
} from "@app/validation/moduleConfig/serial.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Serial = () => {
|
interface SerialModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<SerialValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Serial = ({ onFormInit }: SerialModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: SerialValidation) => {
|
const onSubmit = (data: SerialValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.serial, data, true)) {
|
||||||
|
removeWorkingModuleConfig("serial");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +44,11 @@ export const Serial = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<SerialValidation>
|
<DynamicForm<SerialValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={SerialValidationSchema}
|
validationSchema={SerialValidationSchema}
|
||||||
formId="ModuleConfig_SerialConfig"
|
formId="ModuleConfig_SerialConfig"
|
||||||
defaultValues={moduleConfig.serial}
|
defaultValues={moduleConfig.serial}
|
||||||
|
values={getEffectiveModuleConfig("serial")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("serial.title"),
|
label: t("serial.title"),
|
||||||
|
|||||||
@@ -3,16 +3,34 @@ import {
|
|||||||
StoreForwardValidationSchema,
|
StoreForwardValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/storeForward.ts";
|
} from "@app/validation/moduleConfig/storeForward.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const StoreForward = () => {
|
interface StoreForwardModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<StoreForwardValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StoreForward = ({ onFormInit }: StoreForwardModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: StoreForwardValidation) => {
|
const onSubmit = (data: StoreForwardValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.storeForward, data, true)) {
|
||||||
|
removeWorkingModuleConfig("storeForward");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +44,11 @@ export const StoreForward = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<StoreForwardValidation>
|
<DynamicForm<StoreForwardValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={StoreForwardValidationSchema}
|
validationSchema={StoreForwardValidationSchema}
|
||||||
formId="ModuleConfig_StoreForwardConfig"
|
formId="ModuleConfig_StoreForwardConfig"
|
||||||
defaultValues={moduleConfig.storeForward}
|
defaultValues={moduleConfig.storeForward}
|
||||||
|
values={getEffectiveModuleConfig("storeForward")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("storeForward.title"),
|
label: t("storeForward.title"),
|
||||||
|
|||||||
@@ -3,16 +3,34 @@ import {
|
|||||||
TelemetryValidationSchema,
|
TelemetryValidationSchema,
|
||||||
} from "@app/validation/moduleConfig/telemetry.ts";
|
} from "@app/validation/moduleConfig/telemetry.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||||
|
|
||||||
export const Telemetry = () => {
|
interface TelemetryModuleConfigProps {
|
||||||
const { moduleConfig, setWorkingModuleConfig } = useDevice();
|
onFormInit: DynamicFormFormInit<TelemetryValidation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Telemetry = ({ onFormInit }: TelemetryModuleConfigProps) => {
|
||||||
|
const {
|
||||||
|
moduleConfig,
|
||||||
|
setWorkingModuleConfig,
|
||||||
|
getEffectiveModuleConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { t } = useTranslation("moduleConfig");
|
const { t } = useTranslation("moduleConfig");
|
||||||
|
|
||||||
const onSubmit = (data: TelemetryValidation) => {
|
const onSubmit = (data: TelemetryValidation) => {
|
||||||
|
if (deepCompareConfig(moduleConfig.telemetry, data, true)) {
|
||||||
|
removeWorkingModuleConfig("telemetry");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingModuleConfig(
|
setWorkingModuleConfig(
|
||||||
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -26,9 +44,11 @@ export const Telemetry = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<TelemetryValidation>
|
<DynamicForm<TelemetryValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
onFormInit={onFormInit}
|
||||||
validationSchema={TelemetryValidationSchema}
|
validationSchema={TelemetryValidationSchema}
|
||||||
formId="ModuleConfig_TelemetryConfig"
|
formId="ModuleConfig_TelemetryConfig"
|
||||||
defaultValues={moduleConfig.telemetry}
|
defaultValues={moduleConfig.telemetry}
|
||||||
|
values={getEffectiveModuleConfig("telemetry")}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
label: t("telemetry.title"),
|
label: t("telemetry.title"),
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
|||||||
|
|
||||||
export interface ActionItem {
|
export interface ActionItem {
|
||||||
key: string;
|
key: string;
|
||||||
icon: LucideIcon;
|
icon?: LucideIcon;
|
||||||
iconClasses?: string;
|
iconClasses?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageLayoutProps {
|
export interface PageLayoutProps {
|
||||||
@@ -69,27 +71,39 @@ export const PageLayout = ({
|
|||||||
<span className="text-lg font-medium text-foreground truncate px-2">
|
<span className="text-lg font-medium text-foreground truncate px-2">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center space-x-3 md:space-x-4 shrink-0">
|
<div className="flex items-center space-x-1 md:space-x-2 shrink-0 pr-6">
|
||||||
{actions?.map((action) => (
|
{actions?.map((action) => {
|
||||||
<button
|
return (
|
||||||
key={action.key}
|
<button
|
||||||
type="button"
|
key={action.key}
|
||||||
disabled={action.disabled || action.isLoading}
|
type="button"
|
||||||
className="text-foreground transition-colors hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
disabled={action.disabled || action.isLoading}
|
||||||
onClick={action.onClick}
|
className={cn(
|
||||||
aria-label={action.ariaLabel || `Action ${action.key}`}
|
"flex items-center space-x-2 py-2 px-3 rounded-md",
|
||||||
aria-disabled={action.disabled}
|
"text-foreground transition-colors hover:text-accent",
|
||||||
aria-busy={action.isLoading}
|
"hover:bg-slate-200 disabled:hover:bg-white",
|
||||||
>
|
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||||
<div className="mr-6">
|
action.className,
|
||||||
{action.isLoading ? <Spinner size="md" /> : (
|
|
||||||
<action.icon
|
|
||||||
className={cn("h-5 w-5", action.iconClasses)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
onClick={action.onClick}
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
|||||||
devicePSKBitCount?: number;
|
devicePSKBitCount?: number;
|
||||||
value: string;
|
value: string;
|
||||||
id: string;
|
id: string;
|
||||||
variant: "default" | "invalid";
|
variant: "default" | "invalid" | "dirty";
|
||||||
actionButtons: ActionButton[];
|
actionButtons: ActionButton[];
|
||||||
bits?: { text: string; value: string; key: string }[];
|
bits?: { text: string; value: string; key: string }[];
|
||||||
selectChange: (event: string) => void;
|
selectChange: (event: string) => void;
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
|
|||||||
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
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(
|
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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border-slate-300 dark:border-slate-500",
|
default: "border-slate-300 dark:border-slate-500",
|
||||||
invalid:
|
invalid: `${cnInvalidBase} focus:ring-red-500 dark:focus:ring-red-500`,
|
||||||
"border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-500",
|
dirty: `${cnDirtyBase} focus:ring-sky-500 dark:focus:ring-sky-500`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -49,6 +52,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
variant,
|
variant,
|
||||||
|
disabled,
|
||||||
type = "text",
|
type = "text",
|
||||||
prefix,
|
prefix,
|
||||||
suffix,
|
suffix,
|
||||||
@@ -137,6 +141,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
className,
|
className,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const extrasClassName = cn([
|
||||||
|
variant === "invalid" && `${cnInvalidBase} border-l-0`,
|
||||||
|
variant === "dirty" && `${cnDirtyBase} border-l-0`,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("relative flex w-full items-stretch", containerClassName)}
|
className={cn("relative flex w-full items-stretch", containerClassName)}
|
||||||
@@ -153,6 +162,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -161,6 +171,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
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",
|
"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",
|
!hasActions && "rounded-r-md",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -171,7 +182,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
{hasActions && (
|
{hasActions && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
!hasSuffix && "rounded-r-md",
|
||||||
"bg-white dark:bg-slate-800",
|
"bg-white dark:bg-slate-800",
|
||||||
)}
|
)}
|
||||||
@@ -181,7 +195,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
key={action.id}
|
key={action.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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 &&
|
action.id === "copy-value" && isCopied &&
|
||||||
"text-green-600 dark:text-green-500",
|
"text-green-600 dark:text-green-500",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface SidebarButtonProps {
|
|||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
preventCollapse?: boolean;
|
preventCollapse?: boolean;
|
||||||
|
isDirty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SidebarButton = ({
|
export const SidebarButton = ({
|
||||||
@@ -24,6 +25,7 @@ export const SidebarButton = ({
|
|||||||
onClick,
|
onClick,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
preventCollapse = false,
|
preventCollapse = false,
|
||||||
|
isDirty,
|
||||||
}: SidebarButtonProps) => {
|
}: SidebarButtonProps) => {
|
||||||
const { isCollapsed: isSidebarCollapsed } = useSidebar();
|
const { isCollapsed: isSidebarCollapsed } = useSidebar();
|
||||||
const isButtonCollapsed = isSidebarCollapsed && !preventCollapse;
|
const isButtonCollapsed = isSidebarCollapsed && !preventCollapse;
|
||||||
@@ -64,13 +66,14 @@ export const SidebarButton = ({
|
|||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{!isButtonCollapsed && !active && count && count > 0 && (
|
{!isButtonCollapsed && ((!active && count && count > 0) || isDirty) && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"flex-shrink-0",
|
||||||
"transition-opacity duration-300 ease-in-out",
|
"transition-opacity duration-300 ease-in-out",
|
||||||
isButtonCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
|
isButtonCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
|
||||||
|
isDirty ? "bg-sky-500" : "bg-red-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{count}
|
{count}
|
||||||
|
|||||||
@@ -44,12 +44,19 @@ export const mockDeviceStore: Device = {
|
|||||||
unsafeRoles: false,
|
unsafeRoles: false,
|
||||||
refreshKeys: false,
|
refreshKeys: false,
|
||||||
deleteMessages: false,
|
deleteMessages: false,
|
||||||
|
managedMode: false,
|
||||||
},
|
},
|
||||||
setStatus: vi.fn(),
|
setStatus: vi.fn(),
|
||||||
setConfig: vi.fn(),
|
setConfig: vi.fn(),
|
||||||
setModuleConfig: vi.fn(),
|
setModuleConfig: vi.fn(),
|
||||||
setWorkingConfig: vi.fn(),
|
setWorkingConfig: vi.fn(),
|
||||||
setWorkingModuleConfig: 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(),
|
setHardware: vi.fn(),
|
||||||
setActiveNode: vi.fn(),
|
setActiveNode: vi.fn(),
|
||||||
setPendingSettingsChanges: vi.fn(),
|
setPendingSettingsChanges: vi.fn(),
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ type NodeError = {
|
|||||||
node: number;
|
node: number;
|
||||||
error: string;
|
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 {
|
export interface Device {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -54,6 +62,7 @@ export interface Device {
|
|||||||
unsafeRoles: boolean;
|
unsafeRoles: boolean;
|
||||||
refreshKeys: boolean;
|
refreshKeys: boolean;
|
||||||
deleteMessages: boolean;
|
deleteMessages: boolean;
|
||||||
|
managedMode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
setStatus: (status: Types.DeviceStatusEnum) => void;
|
setStatus: (status: Types.DeviceStatusEnum) => void;
|
||||||
@@ -61,6 +70,26 @@ export interface Device {
|
|||||||
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
|
||||||
setWorkingConfig: (config: Protobuf.Config.Config) => void;
|
setWorkingConfig: (config: Protobuf.Config.Config) => void;
|
||||||
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => 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;
|
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
|
||||||
setActiveNode: (node: number) => void;
|
setActiveNode: (node: number) => void;
|
||||||
setPendingSettingsChanges: (state: boolean) => void;
|
setPendingSettingsChanges: (state: boolean) => void;
|
||||||
@@ -142,6 +171,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
refreshKeys: false,
|
refreshKeys: false,
|
||||||
rebootOTA: false,
|
rebootOTA: false,
|
||||||
deleteMessages: false,
|
deleteMessages: false,
|
||||||
|
managedMode: false,
|
||||||
},
|
},
|
||||||
pendingSettingsChanges: false,
|
pendingSettingsChanges: false,
|
||||||
messageDraft: "",
|
messageDraft: "",
|
||||||
@@ -277,6 +307,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
const index = device.workingConfig.findIndex(
|
const index = device.workingConfig.findIndex(
|
||||||
(wc) => wc.payloadVariant.case === config.payloadVariant.case,
|
(wc) => wc.payloadVariant.case === config.payloadVariant.case,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
device.workingConfig[index] = config;
|
device.workingConfig[index] = config;
|
||||||
} else {
|
} else {
|
||||||
@@ -297,6 +328,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
|
|||||||
wmc.payloadVariant.case ===
|
wmc.payloadVariant.case ===
|
||||||
moduleConfig.payloadVariant.case,
|
moduleConfig.payloadVariant.case,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
device.workingModuleConfig[index] = moduleConfig;
|
device.workingModuleConfig[index] = moduleConfig;
|
||||||
} else {
|
} 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) => {
|
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
|
||||||
set(
|
set(
|
||||||
produce<PrivateDeviceState>((draft) => {
|
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",
|
"unset": "UNSET",
|
||||||
"fallbackName": "Meshtastic {{last4}}",
|
"fallbackName": "Meshtastic {{last4}}",
|
||||||
"formValidation": {
|
"formValidation": {
|
||||||
|
"unsavedChanges": "Unsaved changes",
|
||||||
"tooBig": {
|
"tooBig": {
|
||||||
"string": "Too long, expected less than or equal to {{maximum}} characters.",
|
"string": "Too long, expected less than or equal to {{maximum}} characters.",
|
||||||
"number": "Too big, expected a number smaller than or equal to {{maximum}}.",
|
"number": "Too big, expected a number smaller than or equal to {{maximum}}.",
|
||||||
|
|||||||
@@ -162,5 +162,10 @@
|
|||||||
"choosingRightDeviceRole": "Choosing The Right Device Role",
|
"choosingRightDeviceRole": "Choosing The Right Device Role",
|
||||||
"deviceRoleDocumentation": "Device Role Documentation",
|
"deviceRoleDocumentation": "Device Role Documentation",
|
||||||
"title": "Are you sure?"
|
"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,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@components/UI/Tabs.tsx";
|
} from "@components/UI/Tabs.tsx";
|
||||||
|
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { t } = useTranslation("deviceConfig");
|
||||||
const tabs = [
|
const tabs: TabItem[] = [
|
||||||
{
|
{
|
||||||
|
case: "device",
|
||||||
label: t("page.tabDevice"),
|
label: t("page.tabDevice"),
|
||||||
element: Device,
|
element: Device,
|
||||||
count: 0,
|
count: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "position",
|
||||||
label: t("page.tabPosition"),
|
label: t("page.tabPosition"),
|
||||||
element: Position,
|
element: Position,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "power",
|
||||||
label: t("page.tabPower"),
|
label: t("page.tabPower"),
|
||||||
element: Power,
|
element: Power,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "network",
|
||||||
label: t("page.tabNetwork"),
|
label: t("page.tabNetwork"),
|
||||||
element: Network,
|
element: Network,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "display",
|
||||||
label: t("page.tabDisplay"),
|
label: t("page.tabDisplay"),
|
||||||
element: Display,
|
element: Display,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "lora",
|
||||||
label: t("page.tabLora"),
|
label: t("page.tabLora"),
|
||||||
element: LoRa,
|
element: LoRa,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "bluetooth",
|
||||||
label: t("page.tabBluetooth"),
|
label: t("page.tabBluetooth"),
|
||||||
element: Bluetooth,
|
element: Bluetooth,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "security",
|
||||||
label: t("page.tabSecurity"),
|
label: t("page.tabSecurity"),
|
||||||
element: Security,
|
element: Security,
|
||||||
},
|
},
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
|
const flags = useMemo(
|
||||||
|
() => new Map(tabs.map((tab) => [tab.case, getWorkingConfig(tab.case)])),
|
||||||
|
[tabs, getWorkingConfig],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue={t("page.tabDevice")}>
|
<Tabs defaultValue={t("page.tabDevice")}>
|
||||||
@@ -59,15 +91,27 @@ export const DeviceConfig = () => {
|
|||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={tab.label}
|
key={tab.label}
|
||||||
value={tab.label}
|
value={tab.label}
|
||||||
className="dark:text-white"
|
className="dark:text-white relative"
|
||||||
>
|
>
|
||||||
{tab.label}
|
{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>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsContent key={tab.label} value={tab.label}>
|
<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>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -16,60 +16,96 @@ import {
|
|||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@components/UI/Tabs.tsx";
|
} from "@components/UI/Tabs.tsx";
|
||||||
|
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { t } = useTranslation("moduleConfig");
|
||||||
const tabs = [
|
const tabs: TabItem[] = [
|
||||||
{
|
{
|
||||||
|
case: "mqtt",
|
||||||
label: t("page.tabMqtt"),
|
label: t("page.tabMqtt"),
|
||||||
element: MQTT,
|
element: MQTT,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "serial",
|
||||||
label: t("page.tabSerial"),
|
label: t("page.tabSerial"),
|
||||||
element: Serial,
|
element: Serial,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "externalNotification",
|
||||||
label: t("page.tabExternalNotification"),
|
label: t("page.tabExternalNotification"),
|
||||||
element: ExternalNotification,
|
element: ExternalNotification,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "storeForward",
|
||||||
label: t("page.tabStoreAndForward"),
|
label: t("page.tabStoreAndForward"),
|
||||||
element: StoreForward,
|
element: StoreForward,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "rangeTest",
|
||||||
label: t("page.tabRangeTest"),
|
label: t("page.tabRangeTest"),
|
||||||
element: RangeTest,
|
element: RangeTest,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "telemetry",
|
||||||
label: t("page.tabTelemetry"),
|
label: t("page.tabTelemetry"),
|
||||||
element: Telemetry,
|
element: Telemetry,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "cannedMessage",
|
||||||
label: t("page.tabCannedMessage"),
|
label: t("page.tabCannedMessage"),
|
||||||
element: CannedMessage,
|
element: CannedMessage,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "audio",
|
||||||
label: t("page.tabAudio"),
|
label: t("page.tabAudio"),
|
||||||
element: Audio,
|
element: Audio,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "neighborInfo",
|
||||||
label: t("page.tabNeighborInfo"),
|
label: t("page.tabNeighborInfo"),
|
||||||
element: NeighborInfo,
|
element: NeighborInfo,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "ambientLighting",
|
||||||
label: t("page.tabAmbientLighting"),
|
label: t("page.tabAmbientLighting"),
|
||||||
element: AmbientLighting,
|
element: AmbientLighting,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
case: "detectionSensor",
|
||||||
label: t("page.tabDetectionSensor"),
|
label: t("page.tabDetectionSensor"),
|
||||||
element: DetectionSensor,
|
element: DetectionSensor,
|
||||||
},
|
},
|
||||||
{
|
{ case: "paxcounter", label: t("page.tabPaxcounter"), element: Paxcounter },
|
||||||
label: t("page.tabPaxcounter"),
|
] as const;
|
||||||
element: Paxcounter,
|
|
||||||
},
|
const flags = useMemo(
|
||||||
];
|
() =>
|
||||||
|
new Map(tabs.map((tab) => [tab.case, getWorkingModuleConfig(tab.case)])),
|
||||||
|
[tabs, getWorkingModuleConfig],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue={t("page.tabMqtt")}>
|
<Tabs defaultValue={t("page.tabMqtt")}>
|
||||||
@@ -78,15 +114,27 @@ export const ModuleConfig = () => {
|
|||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={tab.label}
|
key={tab.label}
|
||||||
value={tab.label}
|
value={tab.label}
|
||||||
className="dark:text-white"
|
className="dark:text-white relative"
|
||||||
>
|
>
|
||||||
{tab.label}
|
{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>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<TabsContent key={tab.label} value={tab.label}>
|
<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>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</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 { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { PageLayout } from "@components/PageLayout.tsx";
|
import { PageLayout } from "@components/PageLayout.tsx";
|
||||||
import { Sidebar } from "@components/Sidebar.tsx";
|
import { Sidebar } from "@components/Sidebar.tsx";
|
||||||
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.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 { useToast } from "@core/hooks/useToast.ts";
|
||||||
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
|
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
|
||||||
import { ModuleConfig } from "@pages/Config/ModuleConfig.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 { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
const ConfigPage = () => {
|
const ConfigPage = () => {
|
||||||
const { workingConfig, workingModuleConfig, connection } = useDevice();
|
const {
|
||||||
|
workingConfig,
|
||||||
|
workingModuleConfig,
|
||||||
|
connection,
|
||||||
|
removeWorkingConfig,
|
||||||
|
removeWorkingModuleConfig,
|
||||||
|
setConfig,
|
||||||
|
setModuleConfig,
|
||||||
|
} = useDevice();
|
||||||
const { hasErrors } = useAppStore();
|
const { hasErrors } = useAppStore();
|
||||||
|
|
||||||
const [activeConfigSection, setActiveConfigSection] = useState<
|
const [activeConfigSection, setActiveConfigSection] = useState<
|
||||||
"device" | "module"
|
"device" | "module"
|
||||||
>("device");
|
>("device");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const isError = hasErrors();
|
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
|
const onFormInit = (methods: UseFormReturn) => {
|
||||||
|
setFormMethods(methods);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (hasErrors()) {
|
if (hasErrors()) {
|
||||||
return toast({
|
return toast({
|
||||||
@@ -31,36 +53,49 @@ const ConfigPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (activeConfigSection === "device") {
|
await Promise.all(
|
||||||
await Promise.all(
|
workingConfig.map((newConfig) =>
|
||||||
workingConfig.map((config) =>
|
connection?.setConfig(newConfig).then(() => {
|
||||||
connection?.setConfig(config).then(() =>
|
toast({
|
||||||
toast({
|
title: t("toast.saveSuccess.title"),
|
||||||
title: t("toast.saveSuccess.title"),
|
description: t("toast.saveSuccess.description", {
|
||||||
description: t("toast.saveSuccess.description", {
|
case: newConfig.payloadVariant.case,
|
||||||
case: config.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(
|
removeWorkingConfig();
|
||||||
workingModuleConfig.map((moduleConfig) =>
|
removeWorkingModuleConfig();
|
||||||
connection?.setModuleConfig(moduleConfig).then(() =>
|
});
|
||||||
toast({
|
|
||||||
title: t("toast.saveSuccess.title"),
|
|
||||||
description: t("toast.saveSuccess.description", {
|
|
||||||
case: moduleConfig.payloadVariant.case,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
await connection?.commitEditSettings();
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
toast({
|
toast({
|
||||||
title: t("toast.configSaveError.title"),
|
title: t("toast.configSaveError.title"),
|
||||||
@@ -71,6 +106,15 @@ const ConfigPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (formMethods) {
|
||||||
|
formMethods.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeWorkingConfig();
|
||||||
|
removeWorkingModuleConfig();
|
||||||
|
};
|
||||||
|
|
||||||
const leftSidebar = useMemo(
|
const leftSidebar = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
@@ -83,19 +127,79 @@ const ConfigPage = () => {
|
|||||||
active={activeConfigSection === "device"}
|
active={activeConfigSection === "device"}
|
||||||
onClick={() => setActiveConfigSection("device")}
|
onClick={() => setActiveConfigSection("device")}
|
||||||
Icon={SettingsIcon}
|
Icon={SettingsIcon}
|
||||||
|
isDirty={workingConfig.length > 0}
|
||||||
|
count={workingConfig.length}
|
||||||
/>
|
/>
|
||||||
<SidebarButton
|
<SidebarButton
|
||||||
label={t("navigation.moduleConfig")}
|
label={t("navigation.moduleConfig")}
|
||||||
active={activeConfigSection === "module"}
|
active={activeConfigSection === "module"}
|
||||||
onClick={() => setActiveConfigSection("module")}
|
onClick={() => setActiveConfigSection("module")}
|
||||||
Icon={BoxesIcon}
|
Icon={BoxesIcon}
|
||||||
|
isDirty={workingModuleConfig.length > 0}
|
||||||
|
count={workingModuleConfig.length}
|
||||||
/>
|
/>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
</Sidebar>
|
</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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageLayout
|
<PageLayout
|
||||||
@@ -104,18 +208,11 @@ const ConfigPage = () => {
|
|||||||
label={activeConfigSection === "device"
|
label={activeConfigSection === "device"
|
||||||
? t("navigation.radioConfig")
|
? t("navigation.radioConfig")
|
||||||
: t("navigation.moduleConfig")}
|
: t("navigation.moduleConfig")}
|
||||||
actions={[
|
actions={actions}
|
||||||
{
|
|
||||||
key: "save",
|
|
||||||
icon: isError ? SaveOff : SaveIcon,
|
|
||||||
isLoading: isSaving,
|
|
||||||
disabled: isSaving,
|
|
||||||
iconClasses: isError ? "text-red-400 cursor-not-allowed" : "",
|
|
||||||
onClick: handleSave,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{activeConfigSection === "device" ? <DeviceConfig /> : <ModuleConfig />}
|
{activeConfigSection === "device"
|
||||||
|
? <DeviceConfig onFormInit={onFormInit} />
|
||||||
|
: <ModuleConfig onFormInit={onFormInit} />}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const NetworkValidationSchema = z.object({
|
|||||||
wifiEnabled: z.boolean(),
|
wifiEnabled: z.boolean(),
|
||||||
wifiSsid: z.string().max(33),
|
wifiSsid: z.string().max(33),
|
||||||
wifiPsk: z.string().max(64),
|
wifiPsk: z.string().max(64),
|
||||||
ntpServer: z.string().min(2).max(33),
|
ntpServer: z.string().min(0).max(33),
|
||||||
ethEnabled: z.boolean(),
|
ethEnabled: z.boolean(),
|
||||||
addressMode: AddressModeEnum,
|
addressMode: AddressModeEnum,
|
||||||
ipv4Config: NetworkValidationIpV4ConfigSchema,
|
ipv4Config: NetworkValidationIpV4ConfigSchema,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ describe("RawSecuritySchema", () => {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
expect(
|
expect(
|
||||||
result.error.issues.some((i) =>
|
result.error.issues.some((i) =>
|
||||||
i.message === "formValidation.adminKeyRequiredWhenManaged"
|
i.message === "formValidation.required.managed"
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ describe("ParsedSecuritySchema", () => {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
expect(
|
expect(
|
||||||
result.error.issues.some((i) =>
|
result.error.issues.some((i) =>
|
||||||
i.message === "formValidation.adminKeyRequiredWhenManaged"
|
i.message === "formValidation.required.managed"
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const {
|
|||||||
isValidKey,
|
isValidKey,
|
||||||
} = makePskHelpers([32]); // 256-bit
|
} = makePskHelpers([32]); // 256-bit
|
||||||
|
|
||||||
const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged";
|
const isManagedRequiredMsg = "formValidation.required.managed";
|
||||||
|
|
||||||
function makeSecuritySchema<KeyT>(
|
function makeSecuritySchema<KeyT>(
|
||||||
keyMaker: (optional: boolean) => ZodType<KeyT>,
|
keyMaker: (optional: boolean) => ZodType<KeyT>,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const MqttValidationMapReportSettingsSchema = z.object({
|
export const MqttValidationMapReportSettingsSchema = z.object({
|
||||||
publishIntervalSecs: z.number().optional(),
|
publishIntervalSecs: z.coerce.number().int(),
|
||||||
positionPrecision: z.number().optional(),
|
positionPrecision: z.coerce.number().int(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MqttValidationSchema = z.object({
|
export const MqttValidationSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user