diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index f1c35280c5..65f4ecbb95 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { dropDownSpacingTheme, @@ -15,7 +15,7 @@ export type DropDownOption = { label: string, value: string | number, order?: number -} +}; /** * This component renders a dropdown menu using react-select @@ -75,7 +75,6 @@ const DropDown = ({ const style = dropDownStyle(customCSS ?? {}); useEffect(() => { - // Ensure menu has focus when opened programmatically if (menuIsOpen) { selectRef.current?.focus(); } @@ -87,56 +86,45 @@ const DropDown = ({ } }; - const formatOptions = ( + const formatOptions = useCallback(( unformattedOptions: DropDownOption[], required: boolean, - ) => { - // Translate - // Translating is expensive, skip it if it is not required - if (!skipTranslate) { - unformattedOptions = unformattedOptions.map(option => ({ ...option, label: t(option.label as ParseKeys) })); - } + ): DropDownOption[] => { + let formatted = skipTranslate + ? [...unformattedOptions] + : unformattedOptions.map(option => ({ ...option, label: t(option.label as ParseKeys) })); - // Add "No value" option if (!required) { - unformattedOptions.push({ - value: "", - label: `-- ${t("SELECT_NO_OPTION_SELECTED")} --`, - }); + formatted = [ + ...formatted, + { + value: "", + label: `-- ${t("SELECT_NO_OPTION_SELECTED")} --`, + }, + ]; } - // Sort - /** - * This is used to determine whether any entry of the passed `unformattedOptions` - * contains an `order` field, indicating that a custom ordering for that list - * exists and the list therefore should not be ordered alphabetically. - */ - const hasCustomOrder = unformattedOptions.every(item => { - if (!isJson(item.label)) { + const hasCustomOrder = formatted.every(item => { + if (!isJson(item.label)) return false; + try { + const parsed = JSON.parse(item.label); + return parsed && typeof parsed === "object" && "order" in parsed; + } catch { return false; } - // TODO: Handle JSON parsing errors - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const parsed = JSON.parse(item.label); - return parsed && typeof parsed === "object" && "order" in parsed; }); - if (hasCustomOrder) { - // Apply custom ordering. - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - unformattedOptions.sort((a, b) => JSON.parse(a.label).order - JSON.parse(b.label).order); - } else { - // Apply alphabetical ordering. - unformattedOptions.sort((a, b) => a.label.localeCompare(b.label)); - } + return hasCustomOrder + ? [...formatted].sort((a, b) => JSON.parse(a.label).order - JSON.parse(b.label).order) + : [...formatted].sort((a, b) => a.label.localeCompare(b.label)); + }, [skipTranslate, t]); - return unformattedOptions; - }; + const memoizedDefaultOptions = useMemo(() => + options ? formatOptions(options, required) : true + , [options, required, formatOptions]); const itemHeight = optionHeight; - /** - * Custom component for list virtualization - */ + const MenuList = (props: MenuListProps) => { const { children, maxHeight } = props; @@ -150,7 +138,6 @@ const DropDown = ({ height: maxHeight < (children.length * itemHeight) ? maxHeight : children.length * itemHeight, width: "100%", }} - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment rowProps={{ names: children }} overscanCount={4} /> @@ -163,39 +150,27 @@ const DropDown = ({ names, style, }: RowComponentProps<{ - names: string[]; + names: React.ReactNode[]; }>) { const name = names[index]; return
{name}
; } - const filterOptions = (inputValue: string) => { - if (options) { - return options.filter(option => - option.label.toLowerCase().includes(inputValue.toLowerCase()), - ); - } - return []; - }; - - const loadOptionsAsync = (inputValue: string, callback: (options: DropDownOption[]) => void) => { - setTimeout(async () => { - callback(formatOptions( - fetchOptions ? await fetchOptions(inputValue) : filterOptions(inputValue), - required, - )); - }, 1000); - }; - - const loadOptions = ( - _inputValue: string, + const loadOptions = useCallback(( + inputValue: string, callback: (options: DropDownOption[]) => void, ) => { - callback(formatOptions(filterOptions(_inputValue), required)); - }; - + setTimeout(async () => { + const result = fetchOptions + ? await fetchOptions(inputValue) + : options?.filter(option => + option.label.toLowerCase().includes(inputValue.toLowerCase()) + ) || []; + callback(formatOptions(result, required)); + }, 1000); + }, [fetchOptions, options, required, formatOptions]); - const commonProps: Props = { + const commonProps: Props = { tabIndex: tabIndex, theme: theme => (dropDownSpacingTheme(theme)), styles: style, @@ -203,14 +178,9 @@ const DropDown = ({ autoFocus: autoFocus, isSearchable: true, value: { value: value, label: text === "" ? placeholder : text }, - defaultOptions: options - ? formatOptions( - options, - required, - ) - : true, + defaultOptions: memoizedDefaultOptions, cacheOptions: true, - loadOptions: fetchOptions ? loadOptionsAsync : loadOptions, + loadOptions: loadOptions, placeholder: placeholder, onChange: element => handleChange(element as {value: T, label: string}), menuIsOpen: menuIsOpen, @@ -219,8 +189,6 @@ const DropDown = ({ isDisabled: disabled, openMenuOnFocus: openMenuOnFocus, menuPlacement: menuPlacement ?? "auto", - - // @ts-expect-error: React-Select typing does not account for the typing of option it itself requires components: { MenuList }, };