From 3d3b59686cd6ae3d6261e8efdb18de7b4b5fcd99 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 31 Jan 2025 15:57:23 -0500 Subject: [PATCH] feat: add multi select component. feat: add multi select to position flags section --- src/components/Form/DynamicFormField.tsx | 9 +- src/components/Form/FormMultiSelect.tsx | 60 ++++++++ src/components/Form/FormSelect.tsx | 4 +- .../PageComponents/Config/Position.tsx | 29 +++- src/components/UI/MultiSelect.tsx | 57 +++++++ src/core/hooks/usePositionFlags.ts | 142 ++++++++++++++++++ 6 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 src/components/Form/FormMultiSelect.tsx create mode 100644 src/components/UI/MultiSelect.tsx create mode 100644 src/core/hooks/usePositionFlags.ts diff --git a/src/components/Form/DynamicFormField.tsx b/src/components/Form/DynamicFormField.tsx index 388836d4..545ff0a7 100644 --- a/src/components/Form/DynamicFormField.tsx +++ b/src/components/Form/DynamicFormField.tsx @@ -1,3 +1,7 @@ +import { + type MultiSelectFieldProps, + MultiSelectInput, +} from "@app/components/Form/FormMultiSelect"; import { GenericInput, type InputFieldProps, @@ -19,6 +23,7 @@ import type { Control, FieldValues } from "react-hook-form"; export type FieldProps = | InputFieldProps | SelectFieldProps + | MultiSelectFieldProps | ToggleFieldProps | PasswordGeneratorProps; @@ -58,6 +63,8 @@ export function DynamicFormField({ /> ); case "multiSelect": - return
tmp
; + return ( + + ); } } diff --git a/src/components/Form/FormMultiSelect.tsx b/src/components/Form/FormMultiSelect.tsx new file mode 100644 index 00000000..3f0ed2fd --- /dev/null +++ b/src/components/Form/FormMultiSelect.tsx @@ -0,0 +1,60 @@ +import type { + BaseFormBuilderProps, + GenericFormElementProps, +} from "@components/Form/DynamicForm.tsx"; +import type { FieldValues } from "react-hook-form"; +import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect"; + +export interface MultiSelectFieldProps extends BaseFormBuilderProps { + type: "multiSelect"; + placeholder?: string; + onValueChange: (name: string) => void; + isChecked: (name: string) => boolean; + value: string[]; + properties: BaseFormBuilderProps["properties"] & { + enumValue: { + [s: string]: string | number; + }; + formatEnumName?: boolean; + }; +} + +export function MultiSelectInput({ + field, +}: GenericFormElementProps>) { + const { enumValue, formatEnumName, ...remainingProperties } = + field.properties; + + // Make sure to filter out the UNSET value, as it shouldn't be shown in the UI + const optionsEnumValues = enumValue + ? Object.entries(enumValue) + .filter((value) => typeof value[1] === "number") + .filter((value) => value[0] !== "UNSET") + : []; + + const formatName = (name: string) => { + if (!formatEnumName) return name; + return name + .replace(/_/g, " ") + .toLowerCase() + .split(" ") + .map((s) => s.charAt(0).toUpperCase() + s.substring(1)) + .join(" "); + }; + + return ( + + {optionsEnumValues.map(([name, value]) => ( + field.onValueChange(name)} + > + {formatEnumName ? formatName(name) : name} + + ))} + + ); +} diff --git a/src/components/Form/FormSelect.tsx b/src/components/Form/FormSelect.tsx index 95405127..bd0e24f0 100644 --- a/src/components/Form/FormSelect.tsx +++ b/src/components/Form/FormSelect.tsx @@ -12,7 +12,7 @@ import { import { Controller, type FieldValues } from "react-hook-form"; export interface SelectFieldProps extends BaseFormBuilderProps { - type: "select" | "multiSelect"; + type: "select"; properties: BaseFormBuilderProps["properties"] & { enumValue: { [s: string]: string | number; @@ -51,7 +51,7 @@ export function SelectInput({ {optionsEnumValues.map(([name, value]) => ( - + {formatEnumName ? name .replace(/_/g, " ") diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index 798da2e9..32d83cdd 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -1,25 +1,39 @@ +import { + type FlagName, + usePositionFlags, +} from "@app/core/hooks/usePositionFlags"; import type { PositionValidation } from "@app/validation/config/position.tsx"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/js"; -export const Position = (): JSX.Element => { - const { config, nodes, hardware, setWorkingConfig } = useDevice(); +export const Position = () => { + const { config, setWorkingConfig } = useDevice(); + const { flagsValue, activeFlags, toggleFlag } = usePositionFlags( + config.position.positionFlags ?? 0, + ); const onSubmit = (data: PositionValidation) => { - setWorkingConfig( + return setWorkingConfig( new Protobuf.Config.Config({ payloadVariant: { case: "position", - value: data, + value: { ...data, positionFlags: flagsValue }, }, }), ); }; + const onPositonFlagChange = (name: string) => { + return toggleFlag(name as FlagName); + }; + return ( - onSubmit={onSubmit} + onSubmit={(data) => { + data.positionFlags = flagsValue; + return onSubmit(data); + }} defaultValues={config.position} fieldGroups={[ { @@ -53,7 +67,12 @@ export const Position = (): JSX.Element => { { type: "multiSelect", name: "positionFlags", + value: activeFlags, + isChecked: (name: string) => + activeFlags.includes(name as FlagName), + onValueChange: onPositonFlagChange, label: "Position Flags", + placeholder: "Select position flags...", description: "Configuration options for Position messages", properties: { enumValue: Protobuf.Config.Config_PositionConfig_PositionFlags, diff --git a/src/components/UI/MultiSelect.tsx b/src/components/UI/MultiSelect.tsx new file mode 100644 index 00000000..79466ee0 --- /dev/null +++ b/src/components/UI/MultiSelect.tsx @@ -0,0 +1,57 @@ +import { cn } from "@app/core/utils/cn"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +interface MultiSelectProps { + children: React.ReactNode; + className?: string; +} + +const MultiSelect = ({ children, className = "" }: MultiSelectProps) => { + return ( +
{children}
+ ); +}; + +interface MultiSelectItemProps { + name: string; + value: string; + checked: boolean; + onCheckedChange: (name: string, value: boolean) => void; + children: React.ReactNode; + className?: string; +} + +const MultiSelectItem = ({ + name, + value, + checked, + onCheckedChange, + children, + className = "", +}: MultiSelectItemProps) => { + return ( + onCheckedChange(name, !!val)} + className={cn( + ` + inline-flex items-center rounded-md px-3 py-2 text-sm transition-colors + border border-slate-300 + hover:bg-slate-100 dark:hover:bg-slate-800 + focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 + data-[state=checked]:bg-slate-100 dark:data-[state=checked]:bg-slate-700`, + className, + )} + > + + + + {children} + + ); +}; + +export { MultiSelect, MultiSelectItem }; diff --git a/src/core/hooks/usePositionFlags.ts b/src/core/hooks/usePositionFlags.ts new file mode 100644 index 00000000..aacd28ac --- /dev/null +++ b/src/core/hooks/usePositionFlags.ts @@ -0,0 +1,142 @@ +import { useCallback, useMemo, useState } from "react"; + +export type FlagName = + | "UNSET" + | "ALTITUDE" + | "ALTITUDE_MSL" + | "GEOIDAL_SEPARATION" + | "DOP" + | "HVDOP" + | "SATINVIEW" + | "SEQ_NO" + | "TIMESTAMP" + | "HEADING" + | "SPEED"; + +type UsePositionFlagsProps = { + decode: (value: number) => FlagName[]; + encode: (flagNames: FlagName[]) => number; + hasFlag: (value: number, flagName: FlagName) => boolean; + getAllFlags: () => FlagName[]; + isValidValue: (value: number) => boolean; + flagsValue: number; + activeFlags: FlagName[]; + toggleFlag: (flagName: FlagName) => void; + setFlag: (flagName: FlagName, enabled: boolean) => void; + setFlags: (value: number) => void; + clearFlags: () => void; +}; + +const FLAGS_MAP: ReadonlyMap = new Map([ + ["UNSET", 0], + ["ALTITUDE", 1], + ["ALTITUDE_MSL", 2], + ["GEOIDAL_SEPARATION", 4], + ["DOP", 8], + ["HVDOP", 16], + ["SATINVIEW", 32], + ["SEQ_NO", 64], + ["TIMESTAMP", 128], + ["HEADING", 256], + ["SPEED", 512], +]); + +export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => { + const [flagsValue, setFlagsValue] = useState(initialValue); + + const utils = useMemo(() => { + const decode = (value: number): FlagName[] => { + if (value === 0) return ["UNSET"]; + const activeFlags: FlagName[] = []; + for (const [name, flagValue] of FLAGS_MAP) { + if (flagValue !== 0 && (value & flagValue) === flagValue) { + activeFlags.push(name); + } + } + return activeFlags; + }; + + const encode = (flagNames: FlagName[]): number => { + if (flagNames.includes("UNSET")) { + return 0; + } + return flagNames.reduce((acc, name) => { + const value = FLAGS_MAP.get(name); + if (value === undefined) { + throw new Error(`Invalid flag name: ${name}`); + } + return acc | value; + }, 0); + }; + + const hasFlag = (value: number, flagName: FlagName): boolean => { + const flagValue = FLAGS_MAP.get(flagName); + if (flagValue === undefined) { + throw new Error(`Invalid flag name: ${flagName}`); + } + return (value & flagValue) === flagValue; + }; + + const getAllFlags = (): FlagName[] => { + return Array.from(FLAGS_MAP.keys()); + }; + + const isValidValue = (value: number): boolean => { + const maxValue = Array.from(FLAGS_MAP.values()).reduce( + (a, b) => a + b, + 0, + ); + return Number.isInteger(value) && value >= 0 && value <= maxValue; + }; + + return { + decode, + encode, + hasFlag, + getAllFlags, + isValidValue, + }; + }, []); + + const toggleFlag = useCallback((flagName: FlagName) => { + const flagValue = FLAGS_MAP.get(flagName); + if (flagValue === undefined) { + throw new Error(`Invalid flag name: ${flagName}`); + } + setFlagsValue((prev) => prev ^ flagValue); + }, []); + + const setFlag = useCallback((flagName: FlagName, enabled: boolean) => { + const flagValue = FLAGS_MAP.get(flagName); + if (flagValue === undefined) { + throw new Error(`Invalid flag name: ${flagName}`); + } + setFlagsValue((prev) => (enabled ? prev | flagValue : prev & ~flagValue)); + }, []); + + const setFlags = useCallback( + (value: number) => { + if (!utils.isValidValue(value)) { + throw new Error(`Invalid flags value: ${value}`); + } + setFlagsValue(value); + }, + [utils], + ); + + const clearFlags = useCallback(() => { + setFlagsValue(0); + }, []); + + const activeFlags = utils.decode(flagsValue); + + return { + ...utils, + flagsValue, + activeFlags, + toggleFlag, + setFlag, + setFlags, + clearFlags, + }; +};