[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:
parent
7607cc9b10
commit
bf521b7b03
7 changed files with 107 additions and 14 deletions
|
|
@ -183,6 +183,7 @@ export const MemberDropdownBase = observer(function MemberDropdownBase(props: TM
|
|||
optionsClassName={optionsClassName}
|
||||
placement={placement}
|
||||
referenceElement={referenceElement}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</ComboDropDown>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ export const ModuleDropdownBase = observer(function ModuleDropdownBase(props: TM
|
|||
multiple={multiple}
|
||||
getModuleById={getModuleById}
|
||||
moduleIds={moduleIds}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</ComboDropDown>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue