diff --git a/frontend/src/js/ui-components/InputSelect/InputSelect.tsx b/frontend/src/js/ui-components/InputSelect/InputSelect.tsx index 7e41c99120..896fe94364 100644 --- a/frontend/src/js/ui-components/InputSelect/InputSelect.tsx +++ b/frontend/src/js/ui-components/InputSelect/InputSelect.tsx @@ -24,20 +24,18 @@ import { } from "./InputSelectComponents"; import { optionMatchesQuery } from "./optionMatchesQuery"; -interface Props { - label?: string; - disabled?: boolean; - options: SelectOptionT[]; - tooltip?: string; - indexPrefix?: number; - placeholder?: string; - clearable?: boolean; - smallMenu?: boolean; - className?: string; - dataTestId?: string; - value: SelectOptionT | null; - optional?: boolean; - onChange: (value: SelectOptionT | null) => void; +function filterOptions( + options: SelectOptionT[], + query: string, + sortOptions?: (a: SelectOptionT, b: SelectOptionT, query: string) => number, +) { + const filtered = options.filter((option) => + optionMatchesQuery(option, query), + ); + + return sortOptions + ? filtered.sort((a, b) => sortOptions(a, b, query)) + : filtered; } const InputSelect = ({ @@ -54,7 +52,23 @@ const InputSelect = ({ optional, smallMenu, onChange, -}: Props) => { + sortOptions, +}: { + label?: string; + disabled?: boolean; + options: SelectOptionT[]; + tooltip?: string; + indexPrefix?: number; + placeholder?: string; + clearable?: boolean; + smallMenu?: boolean; + className?: string; + dataTestId?: string; + value: SelectOptionT | null; + optional?: boolean; + onChange: (value: SelectOptionT | null) => void; + sortOptions?: (a: SelectOptionT, b: SelectOptionT, query: string) => number; +}) => { const { t } = useTranslation(); const previousValue = usePrevious(value); const previousOptions = usePrevious(options); @@ -190,28 +204,24 @@ const InputSelect = ({ if (inputValue === value?.label) { setFilteredOptions(options); } else { - setFilteredOptions( - options.filter((option) => optionMatchesQuery(option, inputValue)), - ); + setFilteredOptions(filterOptions(options, inputValue, sortOptions)); } } }, - [inputValue, value, options, previousOptions], + [inputValue, value, options, previousOptions, sortOptions], ); useEffect( function filterOptionsOnChangingInput() { if (exists(inputValue) && inputValue !== previousInputValue) { if (inputValue !== selectedItem?.label) { - setFilteredOptions( - options.filter((option) => optionMatchesQuery(option, inputValue)), - ); + setFilteredOptions(filterOptions(options, inputValue, sortOptions)); } else { setFilteredOptions(options); } } }, - [inputValue, previousInputValue, options, selectedItem], + [inputValue, previousInputValue, options, selectedItem, sortOptions], ); const Select = ( diff --git a/frontend/src/js/upload-concept-list-modal/UploadConceptListModal.tsx b/frontend/src/js/upload-concept-list-modal/UploadConceptListModal.tsx index c6a2e80ea3..73ce0841c9 100644 --- a/frontend/src/js/upload-concept-list-modal/UploadConceptListModal.tsx +++ b/frontend/src/js/upload-concept-list-modal/UploadConceptListModal.tsx @@ -78,7 +78,8 @@ const SxPrimaryButton = styled(PrimaryButton)` flex-shrink: 0; `; const SxInputSelect = styled(InputSelect)` - width: 650px; + width: 60vw; + max-width: 900px; `; const useUnresolvedItemsCount = ( @@ -468,6 +469,51 @@ const UploadConceptListModal = ({ ], ); + // Pretty custom sorting logic, tested with many combinations of + // filters and concepts to "work well". + // Tries to + // - keep concept and its filters together (in asc order of the filterIdx) + // - tries to prioritize concepts that are matched exactly by search query + // But less "set in stone" than it looks + const sortOptions = useCallback( + (a: SelectOptionT, b: SelectOptionT, query: string) => { + const aDetails = selectOptionsDetails[a.value as string]; + const bDetails = selectOptionsDetails[b.value as string]; + + if (aDetails.type === "concept" && bDetails.type === "concept") { + return a.label.localeCompare(b.label); + } + + if (aDetails.type === "filter" && bDetails.type === "filter") { + const sameConcept = aDetails.conceptId === bDetails.conceptId; + + if (sameConcept) { + return aDetails.filterIdx - bDetails.filterIdx; + } + } + + if (aDetails.concept.label === bDetails.concept.label) { + return aDetails.type === "concept" ? -1 : 1; + } + + const aConceptLabel = aDetails.concept.label.toLowerCase(); + const bConceptLabel = bDetails.concept.label.toLowerCase(); + const queryLower = query.toLowerCase(); + + const aConceptLabelEqual = aConceptLabel === queryLower; + const bConceptLabelEqual = bConceptLabel === queryLower; + + if (aConceptLabelEqual) { + return -1; + } else if (bConceptLabelEqual) { + return 1; + } + + return aDetails.concept.label.localeCompare(bDetails.concept.label); + }, + [selectOptionsDetails], + ); + return ( {(!!resolvedFilters || !!resolvedConcepts) && !hasResolvedItems &&