feat: add multi select component. feat: add multi select to position flags section

This commit is contained in:
Dan Ditomaso
2025-01-31 15:57:23 -05:00
parent c7e2baea1b
commit 3d3b59686c
6 changed files with 293 additions and 8 deletions

View File

@@ -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<T> =
| InputFieldProps<T>
| SelectFieldProps<T>
| MultiSelectFieldProps<T>
| ToggleFieldProps<T>
| PasswordGeneratorProps<T>;
@@ -58,6 +63,8 @@ export function DynamicFormField<T extends FieldValues>({
/>
);
case "multiSelect":
return <div>tmp</div>;
return (
<MultiSelectInput field={field} control={control} disabled={disabled} />
);
}
}

View File

@@ -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<T> extends BaseFormBuilderProps<T> {
type: "multiSelect";
placeholder?: string;
onValueChange: (name: string) => void;
isChecked: (name: string) => boolean;
value: string[];
properties: BaseFormBuilderProps<T>["properties"] & {
enumValue: {
[s: string]: string | number;
};
formatEnumName?: boolean;
};
}
export function MultiSelectInput<T extends FieldValues>({
field,
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
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 (
<MultiSelect {...remainingProperties}>
{optionsEnumValues.map(([name, value]) => (
<MultiSelectItem
key={name}
name={name}
value={value.toString()}
checked={field.isChecked(name)}
onCheckedChange={() => field.onValueChange(name)}
>
{formatEnumName ? formatName(name) : name}
</MultiSelectItem>
))}
</MultiSelect>
);
}

View File

@@ -12,7 +12,7 @@ import {
import { Controller, type FieldValues } from "react-hook-form";
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
type: "select" | "multiSelect";
type: "select";
properties: BaseFormBuilderProps<T>["properties"] & {
enumValue: {
[s: string]: string | number;
@@ -51,7 +51,7 @@ export function SelectInput<T extends FieldValues>({
</SelectTrigger>
<SelectContent>
{optionsEnumValues.map(([name, value]) => (
<SelectItem key={name + value} value={value.toString()}>
<SelectItem key={name} value={value.toString()}>
{formatEnumName
? name
.replace(/_/g, " ")

View File

@@ -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 (
<DynamicForm<PositionValidation>
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,

View File

@@ -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 (
<div className={cn("flex flex-wrap gap-2", className)}>{children}</div>
);
};
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 (
<CheckboxPrimitive.Root
name={name}
id={value}
checked={checked}
onCheckedChange={(val) => 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,
)}
>
<CheckboxPrimitive.Indicator>
<Check className="h-4 w-4 animate-in zoom-in duration-200" />
</CheckboxPrimitive.Indicator>
<span className="ml-2">{children}</span>
</CheckboxPrimitive.Root>
);
};
export { MultiSelect, MultiSelectItem };

View File

@@ -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<FlagName, number> = 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<number>(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,
};
};