Browse Source

Merge pull request #439 from danditomaso/fix/validate-ble-pin

fix: Add 6-digit BLE PIN validation and error management
pull/442/head
Dan Ditomaso 1 year ago
committed by GitHub
parent
commit
35be1bee59
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      src/components/Form/FormSelect.tsx
  2. 64
      src/components/PageComponents/Config/Bluetooth.tsx
  3. 1
      src/components/PageComponents/Connect/HTTP.tsx
  4. 17
      src/components/PageLayout.tsx
  5. 104
      src/components/UI/Spinner.tsx
  6. 62
      src/core/stores/appStore.ts
  7. 10
      src/index.css
  8. 2
      src/pages/Config/DeviceConfig.tsx
  9. 81
      src/pages/Config/index.tsx

6
src/components/Form/FormSelect.tsx

@ -13,6 +13,7 @@ import { Controller, type FieldValues } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> { export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select"; type: "select";
selectChange?: (e: string) => void;
properties: BaseFormBuilderProps<T>["properties"] & { properties: BaseFormBuilderProps<T>["properties"] & {
enumValue: { enumValue: {
[s: string]: string | number; [s: string]: string | number;
@ -40,7 +41,10 @@ export function SelectInput<T extends FieldValues>({
: []; : [];
return ( return (
<Select <Select
onValueChange={(e) => onChange(Number.parseInt(e))} onValueChange={(e) => {
if (field.selectChange) field.selectChange(e);
onChange(Number.parseInt(e));
}}
disabled={disabled} disabled={disabled}
value={value?.toString()} value={value?.toString()}
{...remainingProperties} {...remainingProperties}

64
src/components/PageComponents/Config/Bluetooth.tsx

@ -1,23 +1,57 @@
import { useAppStore } from "@app/core/stores/appStore";
import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx"; import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { useState } from "react"; import { useState } from "react";
export const Bluetooth = (): JSX.Element => { export const Bluetooth = () => {
const { config, setWorkingConfig } = useDevice(); const { config, setWorkingConfig } = useDevice();
const [bluetoothValidationText, setBluetoothValidationText] = const {
useState<string>(); hasErrors,
getErrorMessage,
hasFieldError,
addError,
removeError,
clearErrors,
} = useAppStore();
const bluetoothPinChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => { const [bluetoothPin, setBluetoothPin] = useState(
if (e.target.value[0] === "0") { config?.bluetooth?.fixedPin.toString() ?? "",
setBluetoothValidationText("Bluetooth Pin cannot start with 0."); );
} else {
setBluetoothValidationText(""); const validateBluetoothPin = (pin: string) => {
// if empty show error they need a pin set
if (pin === "") {
return addError("fixedPin", "Bluetooth Pin is required");
}
// clear any existing errors
clearErrors();
// if it starts with 0 show error
if (pin[0] === "0") {
return addError("fixedPin", "Bluetooth Pin cannot start with 0");
} }
// if it's not 6 digits show error
if (pin.length < 6) {
return addError("fixedPin", "Pin must be 6 digits");
}
removeError("fixedPin");
};
const bluetoothPinChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const numericValue = e.target.value.replace(/\D/g, "").slice(0, 6);
setBluetoothPin(numericValue);
validateBluetoothPin(numericValue);
}; };
const onSubmit = (data: BluetoothValidation) => { const onSubmit = (data: BluetoothValidation) => {
if (hasErrors()) {
return;
}
setWorkingConfig( setWorkingConfig(
new Protobuf.Config.Config({ new Protobuf.Config.Config({
payloadVariant: { payloadVariant: {
@ -48,6 +82,12 @@ export const Bluetooth = (): JSX.Element => {
name: "mode", name: "mode",
label: "Pairing mode", label: "Pairing mode",
description: "Pin selection behaviour.", description: "Pin selection behaviour.",
selectChange: (e) => {
if (e !== "1") {
setBluetoothPin("");
removeError("fixedPin");
}
},
disabledBy: [ disabledBy: [
{ {
fieldName: "enabled", fieldName: "enabled",
@ -63,7 +103,9 @@ export const Bluetooth = (): JSX.Element => {
name: "fixedPin", name: "fixedPin",
label: "Pin", label: "Pin",
description: "Pin to use when pairing", description: "Pin to use when pairing",
validationText: bluetoothValidationText, validationText: hasFieldError("fixedPin")
? getErrorMessage("fixedPin")
: "",
inputChange: bluetoothPinChangeEvent, inputChange: bluetoothPinChangeEvent,
disabledBy: [ disabledBy: [
{ {
@ -77,7 +119,9 @@ export const Bluetooth = (): JSX.Element => {
fieldName: "enabled", fieldName: "enabled",
}, },
], ],
properties: {}, properties: {
value: bluetoothPin,
},
}, },
], ],
}, },

1
src/components/PageComponents/Connect/HTTP.tsx

@ -70,7 +70,6 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
checked ? setHTTPS(true) : setHTTPS(false); checked ? setHTTPS(true) : setHTTPS(false);
}} }}
// label="Use TLS" // label="Use TLS"
// description="Description" // description="Description"
disabled={ disabled={

17
src/components/PageLayout.tsx

@ -1,6 +1,7 @@
import { cn } from "@app/core/utils/cn.ts"; import { cn } from "@app/core/utils/cn.ts";
import { AlignLeftIcon, type LucideIcon } from "lucide-react"; import { AlignLeftIcon, type LucideIcon } from "lucide-react";
import Footer from "./UI/Footer"; import Footer from "./UI/Footer";
import { Spinner } from "./UI/Spinner";
export interface PageLayoutProps { export interface PageLayoutProps {
label: string; label: string;
@ -10,6 +11,8 @@ export interface PageLayoutProps {
icon: LucideIcon; icon: LucideIcon;
iconClasses?: string; iconClasses?: string;
onClick: () => void; onClick: () => void;
disabled?: boolean;
isLoading?: boolean;
}[]; }[];
} }
@ -18,7 +21,7 @@ export const PageLayout = ({
noPadding, noPadding,
actions, actions,
children, children,
}: PageLayoutProps): JSX.Element => { }: PageLayoutProps) => {
return ( return (
<> <>
<div className="relative flex h-full w-full flex-col"> <div className="relative flex h-full w-full flex-col">
@ -33,14 +36,22 @@ export const PageLayout = ({
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<span className="w-full text-lg font-medium">{label}</span> <span className="w-full text-lg font-medium">{label}</span>
<div className="flex justify-end space-x-4"> <div className="flex justify-end space-x-4">
{actions?.map((action, index) => ( {actions?.map((action) => (
<button <button
key={action.icon.displayName} key={action.icon.displayName}
type="button" type="button"
disabled={action?.disabled}
className="transition-all hover:text-accent" className="transition-all hover:text-accent"
onClick={action.onClick} onClick={action.onClick}
> >
<action.icon className={action.iconClasses} /> {action?.isLoading ? (
<Spinner />
) : (
<action.icon
className={action.iconClasses}
aria-disabled={action.disabled}
/>
)}
</button> </button>
))} ))}
</div> </div>

104
src/components/UI/Spinner.tsx

@ -0,0 +1,104 @@
import { cn } from "@app/core/utils/cn";
interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
size?: "sm" | "md" | "lg";
}
const sizeClasses = {
sm: "h-4 w-4",
md: "h-8 w-8",
lg: "h-12 w-12",
};
export function Spinner({ className, size = "md", ...props }: SpinnerProps) {
return (
<div
aria-label="Loading..."
className={cn(
"flex items-center justify-center fade-in-50 fade-out-50",
className,
)}
{...props}
>
<svg
className={cn("animate-spin-slow stroke-current", sizeClasses[size])}
role="img"
aria-label="Loading spinner"
viewBox="0 0 256 256"
>
<line
x1="128"
y1="32"
x2="128"
y2="64"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="195.9"
y1="60.1"
x2="173.3"
y2="82.7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="224"
y1="128"
x2="192"
y2="128"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="195.9"
y1="195.9"
x2="173.3"
y2="173.3"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="128"
y1="224"
x2="128"
y2="192"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="60.1"
y1="195.9"
x2="82.7"
y2="173.3"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="32"
y1="128"
x2="64"
y2="128"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
<line
x1="60.1"
y1="60.1"
x2="82.7"
y2="82.7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="24"
/>
</svg>
</div>
);
}

62
src/core/stores/appStore.ts

@ -18,6 +18,11 @@ export type AccentColor =
| "purple" | "purple"
| "pink"; | "pink";
interface ErrorState {
field: string;
message: string;
}
interface AppState { interface AppState {
selectedDevice: number; selectedDevice: number;
devices: { devices: {
@ -33,11 +38,11 @@ interface AppState {
nodeNumDetails: number; nodeNumDetails: number;
activeChat: number; activeChat: number;
chatType: "broadcast" | "direct"; chatType: "broadcast" | "direct";
errors: ErrorState[];
setRasterSources: (sources: RasterSource[]) => void; setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void; addRasterSource: (source: RasterSource) => void;
removeRasterSource: (index: number) => void; removeRasterSource: (index: number) => void;
setSelectedDevice: (deviceId: number) => void; setSelectedDevice: (deviceId: number) => void;
addDevice: (device: { id: number; num: number }) => void; addDevice: (device: { id: number; num: number }) => void;
removeDevice: (deviceId: number) => void; removeDevice: (deviceId: number) => void;
@ -49,9 +54,18 @@ interface AppState {
setNodeNumDetails: (nodeNum: number) => void; setNodeNumDetails: (nodeNum: number) => void;
setActiveChat: (chat: number) => void; setActiveChat: (chat: number) => void;
setChatType: (type: "broadcast" | "direct") => void; setChatType: (type: "broadcast" | "direct") => void;
// Error management
hasErrors: () => boolean;
getErrorMessage: (field: string) => string | undefined;
hasFieldError: (field: string) => boolean;
addError: (field: string, message: string) => void;
removeError: (field: string) => void;
clearErrors: () => void;
setNewErrors: (newErrors: ErrorState[]) => void;
} }
export const useAppStore = create<AppState>()((set) => ({ export const useAppStore = create<AppState>()((set, get) => ({
selectedDevice: 0, selectedDevice: 0,
devices: [], devices: [],
currentPage: "messages", currentPage: "messages",
@ -67,6 +81,7 @@ export const useAppStore = create<AppState>()((set) => ({
nodeNumDetails: 0, nodeNumDetails: 0,
activeChat: Types.ChannelNumber.Primary, activeChat: Types.ChannelNumber.Primary,
chatType: "broadcast", chatType: "broadcast",
errors: [],
setRasterSources: (sources: RasterSource[]) => { setRasterSources: (sources: RasterSource[]) => {
set( set(
@ -146,4 +161,47 @@ export const useAppStore = create<AppState>()((set) => ({
set(() => ({ set(() => ({
chatType: type, chatType: type,
})), })),
hasErrors: () => {
const state = get();
return state.errors.length > 0;
},
getErrorMessage: (field: string) => {
const state = get();
return state.errors.find((err) => err.field === field)?.message;
},
hasFieldError: (field: string) => {
const state = get();
return state.errors.some((err) => err.field === field);
},
addError: (field: string, message: string) => {
set(
produce<AppState>((draft) => {
draft.errors = [
...draft.errors.filter((e) => e.field !== field),
{ field, message },
];
}),
);
},
removeError: (field: string) => {
set(
produce<AppState>((draft) => {
draft.errors = draft.errors.filter((e) => e.field !== field);
}),
);
},
clearErrors: () => {
set(
produce<AppState>((draft) => {
draft.errors = [];
}),
);
},
setNewErrors: (newErrors: ErrorState[]) => {
set(
produce<AppState>((draft) => {
draft.errors = newErrors;
}),
);
},
})); }));

10
src/index.css

@ -99,3 +99,13 @@
img { img {
-webkit-user-drag: none; -webkit-user-drag: none;
} }
@keyframes spin-slower {
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin-slower 2s linear infinite;
}

2
src/pages/Config/DeviceConfig.tsx

@ -14,7 +14,7 @@ import {
} from "@components/UI/Tabs.tsx"; } from "@components/UI/Tabs.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
export const DeviceConfig = (): JSX.Element => { export const DeviceConfig = () => {
const { metadata } = useDevice(); const { metadata } = useDevice();
const tabs = [ const tabs = [

81
src/pages/Config/index.tsx

@ -1,3 +1,4 @@
import { useAppStore } from "@app/core/stores/appStore";
import { useDevice } from "@app/core/stores/deviceStore.ts"; import { useDevice } from "@app/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";
@ -6,15 +7,62 @@ 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, SettingsIcon } from "lucide-react"; import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
const ConfigPage = (): JSX.Element => { const ConfigPage = () => {
const { workingConfig, workingModuleConfig, connection } = useDevice(); const { workingConfig, workingModuleConfig, connection } = useDevice();
const { hasErrors } = useAppStore();
const [activeConfigSection, setActiveConfigSection] = useState< const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module" "device" | "module"
>("device"); >("device");
const [isSaving, setIsSaving] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const isError = hasErrors();
const handleSave = async () => {
if (hasErrors()) {
return toast({
title: "Config Errors Exist",
description: "Please fix the configuration errors before saving.",
});
}
setIsSaving(true);
try {
if (activeConfigSection === "device") {
await Promise.all(
workingConfig.map((config) =>
connection?.setConfig(config).then(() =>
toast({
title: "Saving Config",
description: `The configuration change ${config.payloadVariant.case} has been saved.`,
}),
),
),
);
} else {
await Promise.all(
workingModuleConfig.map((moduleConfig) =>
connection?.setModuleConfig(moduleConfig).then(() =>
toast({
title: "Saving Config",
description: `The configuration change ${moduleConfig.payloadVariant.case} has been saved.`,
}),
),
),
);
}
await connection?.commitEditSettings();
} catch (error) {
toast({
title: "Error Saving Config",
description: "An error occurred while saving the configuration.",
});
} finally {
setIsSaving(false);
}
};
return ( return (
<> <>
@ -40,30 +88,11 @@ const ConfigPage = (): JSX.Element => {
} }
actions={[ actions={[
{ {
icon: SaveIcon, icon: isError ? SaveOff : SaveIcon,
async onClick() { isLoading: isSaving,
if (activeConfigSection === "device") { disabled: isSaving,
workingConfig.map( iconClasses: isError ? "text-red-400 cursor-not-allowed" : "",
async (config) => onClick: handleSave,
await connection?.setConfig(config).then(() =>
toast({
title: `Config ${config.payloadVariant.case} saved`,
}),
),
);
} else {
workingModuleConfig.map(
async (moduleConfig) =>
await connection?.setModuleConfig(moduleConfig).then(() =>
toast({
title: `Config ${moduleConfig.payloadVariant.case} saved`,
}),
),
);
}
await connection?.commitEditSettings();
},
}, },
]} ]}
> >

Loading…
Cancel
Save