diff --git a/apps/web/core/components/dropdowns/member/base.tsx b/apps/web/core/components/dropdowns/member/base.tsx index 73190676b10..1bf7aa82018 100644 --- a/apps/web/core/components/dropdowns/member/base.tsx +++ b/apps/web/core/components/dropdowns/member/base.tsx @@ -177,6 +177,7 @@ export const MemberDropdownBase = observer(function MemberDropdownBase(props: TM optionsClassName={optionsClassName} placement={placement} referenceElement={referenceElement} + value={value} /> )} diff --git a/apps/web/core/components/dropdowns/member/member-options.tsx b/apps/web/core/components/dropdowns/member/member-options.tsx index e45e68f18ce..819d880e96a 100644 --- a/apps/web/core/components/dropdowns/member/member-options.tsx +++ b/apps/web/core/components/dropdowns/member/member-options.tsx @@ -11,7 +11,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"; @@ -26,6 +26,7 @@ interface Props { optionsClassName?: string; placement: Placement | undefined; referenceElement: HTMLButtonElement | null; + value?: string[] | string | null; } export const MemberOptions = observer(function MemberOptions(props: Props) { @@ -37,6 +38,7 @@ export const MemberOptions = observer(function MemberOptions(props: Props) { optionsClassName = "", placement, referenceElement, + value, } = props; // router const { workspaceSlug } = useParams(); @@ -111,8 +113,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( diff --git a/apps/web/core/components/dropdowns/module/base.tsx b/apps/web/core/components/dropdowns/module/base.tsx index 83459d17d5e..16ce078c4f4 100644 --- a/apps/web/core/components/dropdowns/module/base.tsx +++ b/apps/web/core/components/dropdowns/module/base.tsx @@ -187,6 +187,7 @@ export const ModuleDropdownBase = observer(function ModuleDropdownBase(props: TM multiple={multiple} getModuleById={getModuleById} moduleIds={moduleIds} + value={value} /> )} diff --git a/apps/web/core/components/dropdowns/module/module-options.tsx b/apps/web/core/components/dropdowns/module/module-options.tsx index 243684987eb..77ac86333bc 100644 --- a/apps/web/core/components/dropdowns/module/module-options.tsx +++ b/apps/web/core/components/dropdowns/module/module-options.tsx @@ -7,7 +7,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"; @@ -27,10 +27,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(null); // states @@ -100,8 +101,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 ( diff --git a/apps/web/core/components/dropdowns/project/base.tsx b/apps/web/core/components/dropdowns/project/base.tsx index e3362e83ffc..24ad7156cc9 100644 --- a/apps/web/core/components/dropdowns/project/base.tsx +++ b/apps/web/core/components/dropdowns/project/base.tsx @@ -8,7 +8,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"; @@ -110,10 +110,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 => o !== undefined), + value + ); const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({ dropdownRef, diff --git a/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx b/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx index cc52345a50b..d49658bc29a 100644 --- a/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx +++ b/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx @@ -14,6 +14,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"; @@ -112,8 +113,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, { @@ -264,7 +268,7 @@ export function LabelDropdown(props: ILabelDropdownProps) {
{isLoading ? (

{t("common.loading")}

- ) : filteredOptions.length > 0 ? ( + ) : filteredOptions && filteredOptions.length > 0 ? ( filteredOptions.map((option) => ( { 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 = ( + 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 = ( + 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; + }); +};