Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 43 additions & 75 deletions src/components/shared/DropDown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
dropDownSpacingTheme,
Expand All @@ -15,7 +15,7 @@
label: string,
value: string | number,
order?: number
}
};

/**
* This component renders a dropdown menu using react-select
Expand Down Expand Up @@ -75,7 +75,6 @@
const style = dropDownStyle(customCSS ?? {});

useEffect(() => {
// Ensure menu has focus when opened programmatically
if (menuIsOpen) {
selectRef.current?.focus();
}
Expand All @@ -87,56 +86,45 @@
}
};

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<DropDownOption, false>) => {
const { children, maxHeight } = props;

Expand All @@ -150,7 +138,6 @@
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}
/>
Expand All @@ -163,54 +150,37 @@
names,
style,
}: RowComponentProps<{
names: string[];
names: React.ReactNode[];
}>) {
const name = names[index];
return <div style={style}>{name}</div>;
}

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,
defaultMenuIsOpen: defaultOpen,
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,
Expand All @@ -219,9 +189,7 @@
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 },

Check failure on line 192 in src/components/shared/DropDown.tsx

View workflow job for this annotation

GitHub Actions / check-npm-build

Type '(props: MenuListProps<DropDownOption, false>) => JSX.Element | null' is not assignable to type 'ComponentType<MenuListProps<unknown, boolean, GroupBase<unknown>>> | undefined'.
};

return creatable ? (
Expand Down
Loading