[WEB-1201] chore: dropdown options hierarchy improvements (#8501)

* chore: sortBySelectedFirst and sortByCurrentUserThenSelected utils added

* chore: members dropdown updated

* chore: module dropdown updated

* chore: project and label dropdown updated

* chore: code refactor
This commit is contained in:
Anmol Singh Bhatia 2026-02-13 18:50:18 +05:30 committed by GitHub
parent 7607cc9b10
commit bf521b7b03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 107 additions and 14 deletions

View file

@ -183,6 +183,7 @@ export const MemberDropdownBase = observer(function MemberDropdownBase(props: TM
optionsClassName={optionsClassName}
placement={placement}
referenceElement={referenceElement}
value={value}
/>
)}
</ComboDropDown>

View file

@ -17,7 +17,7 @@ import { CheckIcon, SearchIcon, SuspendedUserIcon } from "@plane/propel/icons";
import { EPillSize, EPillVariant, Pill } from "@plane/propel/pill";
import type { IUserLite } from "@plane/types";
import { Avatar } from "@plane/ui";
import { cn, getFileURL } from "@plane/utils";
import { cn, getFileURL, sortByCurrentUserThenSelected } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
@ -32,6 +32,7 @@ interface Props {
optionsClassName?: string;
placement: Placement | undefined;
referenceElement: HTMLButtonElement | null;
value?: string[] | string | null;
}
export const MemberOptions = observer(function MemberOptions(props: Props) {
@ -43,6 +44,7 @@ export const MemberOptions = observer(function MemberOptions(props: Props) {
optionsClassName = "",
placement,
referenceElement,
value,
} = props;
// router
const { workspaceSlug } = useParams();
@ -117,8 +119,11 @@ export const MemberOptions = observer(function MemberOptions(props: Props) {
})
.filter((o) => !!o);
const filteredOptions =
query === "" ? options : options?.filter((o) => o?.query.toLowerCase().includes(query.toLowerCase()));
const filteredOptions = sortByCurrentUserThenSelected(
query === "" ? options : options?.filter((o) => o?.query.toLowerCase().includes(query.toLowerCase())),
value,
currentUser?.id
);
return createPortal(
<Combobox.Options data-prevent-outside-click static>

View file

@ -193,6 +193,7 @@ export const ModuleDropdownBase = observer(function ModuleDropdownBase(props: TM
multiple={multiple}
getModuleById={getModuleById}
moduleIds={moduleIds}
value={value}
/>
)}
</ComboDropDown>

View file

@ -13,7 +13,7 @@ import { Combobox } from "@headlessui/react";
import { useTranslation } from "@plane/i18n";
import { CheckIcon, SearchIcon, ModuleIcon } from "@plane/propel/icons";
import type { IModule } from "@plane/types";
import { cn } from "@plane/utils";
import { cn, sortBySelectedFirst } from "@plane/utils";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
@ -33,10 +33,11 @@ interface Props {
onDropdownOpen?: () => void;
placement: Placement | undefined;
referenceElement: HTMLButtonElement | null;
value?: string[] | string | null;
}
export const ModuleOptions = observer(function ModuleOptions(props: Props) {
const { getModuleById, isOpen, moduleIds, multiple, onDropdownOpen, placement, referenceElement } = props;
const { getModuleById, isOpen, moduleIds, multiple, onDropdownOpen, placement, referenceElement, value } = props;
// refs
const inputRef = useRef<HTMLInputElement | null>(null);
// states
@ -106,8 +107,10 @@ export const ModuleOptions = observer(function ModuleOptions(props: Props) {
),
});
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
const filteredOptions = sortBySelectedFirst(
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())),
value
);
return (
<Combobox.Options className="fixed z-10" static>

View file

@ -14,7 +14,7 @@ import { useTranslation } from "@plane/i18n";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { CheckIcon, SearchIcon, ProjectIcon, ChevronDownIcon } from "@plane/propel/icons";
import { ComboDropDown } from "@plane/ui";
import { cn } from "@plane/utils";
import { cn, sortBySelectedFirst } from "@plane/utils";
// components
// hooks
import { useDropdown } from "@/hooks/use-dropdown";
@ -116,10 +116,13 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
};
});
const filteredOptions =
query === ""
const filteredOptions = sortBySelectedFirst(
(query === ""
? options?.filter((o) => o?.value !== currentProjectId)
: options?.filter((o) => o?.value !== currentProjectId && o?.query.toLowerCase().includes(query.toLowerCase()));
: options?.filter((o) => o?.value !== currentProjectId && o?.query.toLowerCase().includes(query.toLowerCase()))
)?.filter((o): o is NonNullable<typeof o> => o !== undefined),
value
);
const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({
dropdownRef,

View file

@ -20,6 +20,7 @@ import type { IIssueLabel } from "@plane/types";
import { EUserProjectRoles } from "@plane/types";
// components
import { ComboDropDown } from "@plane/ui";
import { sortBySelectedFirst } from "@plane/utils";
// hooks
import { useLabel } from "@/hooks/store/use-label";
import { useUserPermissions } from "@/hooks/store/user";
@ -118,8 +119,11 @@ export function LabelDropdown(props: ILabelDropdownProps) {
const filteredOptions = useMemo(
() =>
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())),
[options, query]
sortBySelectedFirst(
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())),
value
),
[options, query, value]
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
@ -270,7 +274,7 @@ export function LabelDropdown(props: ILabelDropdownProps) {
<div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}>
{isLoading ? (
<p className="text-center text-secondary">{t("common.loading")}</p>
) : filteredOptions.length > 0 ? (
) : filteredOptions && filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}

View file

@ -200,3 +200,79 @@ export const convertStringArrayToBooleanObject = (arrayStrings: string[]) => {
return obj;
};
/**
* @description Sorts dropdown options with selected items appearing first
* @param {T[]} options Array of dropdown options with value property
* @param {string[] | string | null | undefined} selectedValues Selected value(s) - array for multi-select, string for single-select
* @returns {T[]} Sorted array with selected items first
* @example
* const options = [{value: '1', label: 'A'}, {value: '2', label: 'B'}];
* sortBySelectedFirst(options, ['2']) // returns [{value: '2', label: 'B'}, {value: '1', label: 'A'}]
*/
export const sortBySelectedFirst = <T extends { value: string | null }>(
options: T[] | undefined,
selectedValues: string[] | string | null | undefined
): T[] | undefined => {
if (!options || options.length === 0) return options;
// Normalize selectedValues to array for consistent handling
const selectedSet = new Set(Array.isArray(selectedValues) ? selectedValues : selectedValues ? [selectedValues] : []);
if (selectedSet.size === 0) return options;
// Create a shallow copy to avoid mutating the original array
return [...options].sort((a, b) => {
const aSelected = a.value !== null && selectedSet.has(a.value);
const bSelected = b.value !== null && selectedSet.has(b.value);
// If both selected or both unselected, maintain original order
if (aSelected === bSelected) return 0;
// Selected items come first
return aSelected ? -1 : 1;
});
};
/**
* @description Sorts dropdown options with current user first, then selected items, then unselected items
* @param {T[]} options Array of dropdown options with value property
* @param {string[] | string | null | undefined} selectedValues Selected value(s) - array for multi-select, string for single-select
* @param {string | undefined} currentUserId ID of the current user to prioritize
* @returns {T[]} Sorted array with current user first, then selected items, then unselected
* @example
* const options = [{value: 'user1'}, {value: 'user2'}, {value: 'user3'}];
* sortByCurrentUserThenSelected(options, ['user2'], 'user3')
* // returns [{value: 'user3'}, {value: 'user2'}, {value: 'user1'}]
*/
export const sortByCurrentUserThenSelected = <T extends { value: string | null }>(
options: T[] | undefined,
selectedValues: string[] | string | null | undefined,
currentUserId: string | undefined
): T[] | undefined => {
if (!options || options.length === 0) return options;
// Normalize selectedValues to array for consistent handling
const selectedSet = new Set(Array.isArray(selectedValues) ? selectedValues : selectedValues ? [selectedValues] : []);
// Create a shallow copy to avoid mutating the original array
return [...options].sort((a, b) => {
const aIsCurrent = currentUserId && a.value === currentUserId;
const bIsCurrent = currentUserId && b.value === currentUserId;
// Current user always comes first
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
if (aIsCurrent && bIsCurrent) return 0;
// If neither is current user, sort by selection state
const aSelected = a.value !== null && selectedSet.has(a.value);
const bSelected = b.value !== null && selectedSet.has(b.value);
// If both selected or both unselected, maintain original order
if (aSelected === bSelected) return 0;
// Selected items come before unselected
return aSelected ? -1 : 1;
});
};