import React, { useCallback, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { SvgIconComponent } from "@mui/icons-material"; import AbcIcon from "@mui/icons-material/Abc"; import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import AlternateEmailIcon from "@mui/icons-material/AlternateEmail"; import ClassIcon from "@mui/icons-material/Class"; import LanguageIcon from "@mui/icons-material/Language"; import NumbersIcon from "@mui/icons-material/Numbers"; import PhoneIcon from "@mui/icons-material/Phone"; import QuestionMarkOutlinedIcon from "@mui/icons-material/QuestionMarkOutlined"; import SearchIcon from "@mui/icons-material/Search"; import { SxProps } from "@mui/material"; import FormControl from "@mui/material/FormControl"; import IconButton from "@mui/material/IconButton"; import Stack from "@mui/material/Stack"; import { MuiChipsInputChipComponent, MuiChipsInputChipProps, MuiChipsInputProps, } from "mui-chips-input"; import { isValidPhoneNumber, parsePhoneNumberWithError } from "libphonenumber-js"; import { useSnackbar } from "notistack"; import { useI18n } from "../contexts/I18nContext"; import { isEmail } from "../util/is_email"; import { isPositiveInteger } from "../util/is_positive_integer"; import ChipsInput from "./ChipsInput"; const styles: Record = { root: { position: "relative", ml: 2, width: "100%", maxWidth: 420, }, formControl: { justifyContent: "center", }, button: { position: "absolute", zIndex: 1, ml: 2, }, }; export type ChipType = | "code" | "email" | "name" | "phone" | "purchase_number" | "query" | "search" | "site_code" | "variant"; export function assertChipType(type: string): asserts type is ChipType { // prettier-ignore if (!["code", "email", "name", "phone", "purchase_number", "query", "search", "site_code", "variant"].includes(type) ) { throw new Error(`Invalid chip type: ${type}`); } } export const ChipIcons: Record = { code: NumbersIcon, email: AlternateEmailIcon, name: AbcIcon, phone: PhoneIcon, purchase_number: NumbersIcon, query: QuestionMarkOutlinedIcon, search: AccountCircleIcon, site_code: LanguageIcon, variant: ClassIcon, }; export const getChipIcon = (type: ChipType): SvgIconComponent => { return ChipIcons[type] ?? QuestionMarkOutlinedIcon; }; /** * Parses a string as into the {@link ChipsSearchInput} type returning undefined if it was not specified * which type or if it could not be inferred from its format. This function currently supports * parsing or infering emails, phone numbers and purchase numbers as {@link ChipsSearchInput}s. */ export const parseSearchInput = (allowedTypes: ChipType[], fallbackType: string) => (input: string): ChipsSearchInput | undefined => { if (!input) return undefined; if (input.includes(":")) { // eslint-disable-next-line prefer-const let [type, value] = input.split(":"); type = type.toLowerCase(); assertChipType(type); if (!allowedTypes.includes(type)) { throw new Error(`Invalid type: ${type}`); } if (allowedTypes.includes("phone") && type === "phone") { if (isValidPhoneNumber(input, "SE")) { return { phone: parsePhoneNumberWithError(input, "SE").format("E.164") }; } else { throw new Error(`Invalid phone number: ${input}`); } } return { [type]: value } as ChipsSearchInput; } if (allowedTypes.includes("email") && isEmail(input)) { return { email: input }; } if (allowedTypes.includes("purchase_number") && input.startsWith("#")) { const purchase_number = input.slice(1); if (isPositiveInteger(purchase_number)) { return { purchase_number }; } } if ( allowedTypes.includes("phone") && (input.startsWith("0") || input.startsWith("+")) && isValidPhoneNumber(input, "SE") // TODO: i18n ) { return { phone: parsePhoneNumberWithError(input, "SE").format("E.164") }; } return { [fallbackType]: input } as ChipsSearchInput; }; export type ChipsSearchInput = { [K in ChipType]: { [P in K]: string } }[ChipType]; export interface SearchBarProps { allowedTypes: ChipType[]; fallbackType?: string; onChange?: (inputs: ChipsSearchInput[]) => void; onSubmit?: (event: React.FormEvent) => void; placeholder: string; } export const ChipsSearchBar: React.FC = ({ allowedTypes, fallbackType = "query", placeholder, onChange, onSubmit, }) => { const { enqueueSnackbar } = useSnackbar(); const [searchParams, setSearchParams] = useSearchParams(); const { t } = useI18n(); const parseInput = useMemo(() => parseSearchInput(allowedTypes, fallbackType), [allowedTypes]); const [inputs, setInputs] = useState( () => allowedTypes .flatMap((type) => searchParams.getAll(type).map((value) => { try { assertChipType(type); return { [type.toLowerCase()]: value }; } catch (error) { console.error("[CHIPS_SERACH_BAR]", error); enqueueSnackbar(t("Invalid search query found when loading page."), { variant: "warning", }); return null; } }), ) .filter(Boolean) as ChipsSearchInput[], ); const chips = useMemo( () => inputs .map((i) => { const [key, value] = Object.entries(i)[0]; assertChipType(key); return allowedTypes.includes(key) ? value : null; }) .filter(Boolean) as string[], [inputs], ); const setSearchInputs = useCallback( (inputs: ChipsSearchInput[]) => { setSearchParams((sp) => { const entries = Array.from(sp.entries()).filter(([key]) => { assertChipType(key); return !allowedTypes.includes(key); }); return [...entries, ...inputs.flatMap(Object.entries)]; }); }, [setSearchParams], ); const handleChange = useCallback( (inputs: string[]) => { try { const newInputs = inputs.map(parseInput).filter(Boolean) as ChipsSearchInput[]; setInputs(newInputs); if (onChange != null) { onChange(newInputs); } else { setSearchInputs(newInputs); } } catch (error) { console.error("[CHIPS_SERACH_BAR]", error); enqueueSnackbar(t("Invalid search query."), { variant: "warning" }); } }, [setInputs, onChange], ); const renderChip: MuiChipsInputProps["renderChip"] = useCallback( (Component: MuiChipsInputChipComponent, key: React.Key, props: MuiChipsInputChipProps) => { try { const input = inputs.find((i) => { const value = Object.values(i)[0]; return value === props.title; }); if (input == null) return ; const type = Object.keys(input)[0]; assertChipType(type); const Icon = getChipIcon(type); return } />; } catch (error) { console.error("[CHIPS_SERACH_BAR]", error); enqueueSnackbar(t("Invalid search query."), { variant: "warning" }); return ; } }, [chips, enqueueSnackbar, parseInput, t], ); return ( 0 ? "" : placeholder} renderChip={renderChip} size="small" validate={(input) => { try { parseInput(input); return true; } catch { enqueueSnackbar(t("Invalid search type."), { variant: "warning" }); return false; } }} value={chips} variant="outlined" onChange={handleChange} /> ); };