6 changed files with 293 additions and 8 deletions
@ -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> |
|||
); |
|||
} |
|||
@ -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, |
|||
}; |
|||
}; |
|||
Loading…
Reference in new issue