Skip to content
Open
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions apps/web/core/components/dropdowns/member/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export const MemberDropdownBase = observer(function MemberDropdownBase(props: TM
optionsClassName={optionsClassName}
placement={placement}
referenceElement={referenceElement}
value={value}
/>
)}
</ComboDropDown>
Expand Down
11 changes: 8 additions & 3 deletions apps/web/core/components/dropdowns/member/member-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand All @@ -37,6 +38,7 @@ export const MemberOptions = observer(function MemberOptions(props: Props) {
optionsClassName = "",
placement,
referenceElement,
value,
} = props;
// router
const { workspaceSlug } = useParams();
Expand Down Expand Up @@ -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(
<Combobox.Options data-prevent-outside-click static>
Expand Down
1 change: 1 addition & 0 deletions apps/web/core/components/dropdowns/module/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export const ModuleDropdownBase = observer(function ModuleDropdownBase(props: TM
multiple={multiple}
getModuleById={getModuleById}
moduleIds={moduleIds}
value={value}
/>
)}
</ComboDropDown>
Expand Down
11 changes: 7 additions & 4 deletions apps/web/core/components/dropdowns/module/module-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<HTMLInputElement | null>(null);
// states
Expand Down Expand Up @@ -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 (
<Combobox.Options className="fixed z-10" static>
Expand Down
11 changes: 7 additions & 4 deletions apps/web/core/components/dropdowns/project/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof o> => o !== undefined),
value
);

const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({
dropdownRef,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -264,7 +268,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}
Expand Down
76 changes: 76 additions & 0 deletions packages/utils/src/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,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;
});
};
Loading