feat: add multi select component. feat: add multi select to position flags section
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
type MultiSelectFieldProps,
|
||||||
|
MultiSelectInput,
|
||||||
|
} from "@app/components/Form/FormMultiSelect";
|
||||||
import {
|
import {
|
||||||
GenericInput,
|
GenericInput,
|
||||||
type InputFieldProps,
|
type InputFieldProps,
|
||||||
@@ -19,6 +23,7 @@ import type { Control, FieldValues } from "react-hook-form";
|
|||||||
export type FieldProps<T> =
|
export type FieldProps<T> =
|
||||||
| InputFieldProps<T>
|
| InputFieldProps<T>
|
||||||
| SelectFieldProps<T>
|
| SelectFieldProps<T>
|
||||||
|
| MultiSelectFieldProps<T>
|
||||||
| ToggleFieldProps<T>
|
| ToggleFieldProps<T>
|
||||||
| PasswordGeneratorProps<T>;
|
| PasswordGeneratorProps<T>;
|
||||||
|
|
||||||
@@ -58,6 +63,8 @@ export function DynamicFormField<T extends FieldValues>({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "multiSelect":
|
case "multiSelect":
|
||||||
return <div>tmp</div>;
|
return (
|
||||||
|
<MultiSelectInput field={field} control={control} disabled={disabled} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { Controller, type FieldValues } from "react-hook-form";
|
import { Controller, type FieldValues } from "react-hook-form";
|
||||||
|
|
||||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "select" | "multiSelect";
|
type: "select";
|
||||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||||
enumValue: {
|
enumValue: {
|
||||||
[s: string]: string | number;
|
[s: string]: string | number;
|
||||||
@@ -51,7 +51,7 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{optionsEnumValues.map(([name, value]) => (
|
{optionsEnumValues.map(([name, value]) => (
|
||||||
<SelectItem key={name + value} value={value.toString()}>
|
<SelectItem key={name} value={value.toString()}>
|
||||||
{formatEnumName
|
{formatEnumName
|
||||||
? name
|
? name
|
||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
|
|||||||
@@ -1,25 +1,39 @@
|
|||||||
|
import {
|
||||||
|
type FlagName,
|
||||||
|
usePositionFlags,
|
||||||
|
} from "@app/core/hooks/usePositionFlags";
|
||||||
import type { PositionValidation } from "@app/validation/config/position.tsx";
|
import type { PositionValidation } from "@app/validation/config/position.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";
|
||||||
|
|
||||||
export const Position = (): JSX.Element => {
|
export const Position = () => {
|
||||||
const { config, nodes, hardware, setWorkingConfig } = useDevice();
|
const { config, setWorkingConfig } = useDevice();
|
||||||
|
const { flagsValue, activeFlags, toggleFlag } = usePositionFlags(
|
||||||
|
config.position.positionFlags ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
const onSubmit = (data: PositionValidation) => {
|
const onSubmit = (data: PositionValidation) => {
|
||||||
setWorkingConfig(
|
return setWorkingConfig(
|
||||||
new Protobuf.Config.Config({
|
new Protobuf.Config.Config({
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
case: "position",
|
case: "position",
|
||||||
value: data,
|
value: { ...data, positionFlags: flagsValue },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onPositonFlagChange = (name: string) => {
|
||||||
|
return toggleFlag(name as FlagName);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicForm<PositionValidation>
|
<DynamicForm<PositionValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={(data) => {
|
||||||
|
data.positionFlags = flagsValue;
|
||||||
|
return onSubmit(data);
|
||||||
|
}}
|
||||||
defaultValues={config.position}
|
defaultValues={config.position}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
@@ -53,7 +67,12 @@ export const Position = (): JSX.Element => {
|
|||||||
{
|
{
|
||||||
type: "multiSelect",
|
type: "multiSelect",
|
||||||
name: "positionFlags",
|
name: "positionFlags",
|
||||||
|
value: activeFlags,
|
||||||
|
isChecked: (name: string) =>
|
||||||
|
activeFlags.includes(name as FlagName),
|
||||||
|
onValueChange: onPositonFlagChange,
|
||||||
label: "Position Flags",
|
label: "Position Flags",
|
||||||
|
placeholder: "Select position flags...",
|
||||||
description: "Configuration options for Position messages",
|
description: "Configuration options for Position messages",
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_PositionConfig_PositionFlags,
|
enumValue: Protobuf.Config.Config_PositionConfig_PositionFlags,
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user