fix: navigate with arrow keys in select/multi-select (#5983)
closes: #4977 https://github.com/twentyhq/twenty/assets/13139771/8121814c-9a8a-4a8d-9290-1aebe145220f --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
@ -1,15 +1,9 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useObjectRecordMultiSelectScopedStates';
|
||||
import { MultipleObjectRecordOnClickOutsideEffect } from '@/object-record/relation-picker/components/MultipleObjectRecordOnClickOutsideEffect';
|
||||
import { MultipleObjectRecordSelectItem } from '@/object-record/relation-picker/components/MultipleObjectRecordSelectItem';
|
||||
import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId';
|
||||
import { useRelationPickerScopedStates } from '@/object-record/relation-picker/hooks/internal/useRelationPickerScopedStates';
|
||||
import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
|
||||
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
|
||||
import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton';
|
||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
@ -17,10 +11,18 @@ import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/Dropdow
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
|
||||
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
|
||||
import styled from '@emotion/styled';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { IconPlus, isDefined } from 'twenty-ui';
|
||||
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
export const StyledSelectableItem = styled(SelectableItem)`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@ -35,6 +37,8 @@ export const MultiRecordSelect = ({
|
||||
onCreate?: ((searchInput?: string) => void) | (() => void);
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
|
||||
|
||||
const relationPickerScopedId = useAvailableScopeIdOrThrow(
|
||||
RelationPickerScopeInternalContext,
|
||||
@ -43,6 +47,9 @@ export const MultiRecordSelect = ({
|
||||
const { objectRecordsIdsMultiSelectState, recordMultiSelectIsLoadingState } =
|
||||
useObjectRecordMultiSelectScopedStates(relationPickerScopedId);
|
||||
|
||||
const { handleResetSelectedPosition } = useSelectableList(
|
||||
MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID,
|
||||
);
|
||||
const recordMultiSelectIsLoading = useRecoilValue(
|
||||
recordMultiSelectIsLoadingState,
|
||||
);
|
||||
@ -62,6 +69,21 @@ export const MultiRecordSelect = ({
|
||||
leading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setHotkeyScope(relationPickerScopedId);
|
||||
}, [setHotkeyScope, relationPickerScopedId]);
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Escape,
|
||||
() => {
|
||||
onSubmit?.();
|
||||
goBackToPreviousHotkeyScope();
|
||||
handleResetSelectedPosition();
|
||||
},
|
||||
relationPickerScopedId,
|
||||
[onSubmit, goBackToPreviousHotkeyScope, handleResetSelectedPosition],
|
||||
);
|
||||
|
||||
const debouncedOnCreate = useDebouncedCallback(
|
||||
() => onCreate?.(relationPickerSearchFilter),
|
||||
500,
|
||||
@ -97,14 +119,21 @@ export const MultiRecordSelect = ({
|
||||
<SelectableList
|
||||
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
|
||||
selectableItemIdArray={objectRecordsIdsMultiSelect}
|
||||
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
|
||||
hotkeyScope={relationPickerScopedId}
|
||||
onEnter={(selectedId) => {
|
||||
onChange?.(selectedId);
|
||||
handleResetSelectedPosition();
|
||||
}}
|
||||
>
|
||||
{objectRecordsIdsMultiSelect?.map((recordId) => {
|
||||
return (
|
||||
<MultipleObjectRecordSelectItem
|
||||
key={recordId}
|
||||
objectRecordId={recordId}
|
||||
onChange={onChange}
|
||||
onChange={(recordId) => {
|
||||
onChange?.(recordId);
|
||||
handleResetSelectedPosition();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -35,13 +35,19 @@ export const MultipleObjectRecordSelectItem = ({
|
||||
RelationPickerScopeInternalContext,
|
||||
);
|
||||
|
||||
const { objectRecordMultiSelectFamilyState } =
|
||||
useObjectRecordMultiSelectScopedStates(scopeId);
|
||||
const {
|
||||
objectRecordMultiSelectFamilyState,
|
||||
objectRecordMultiSelectCheckedRecordsIdsState,
|
||||
} = useObjectRecordMultiSelectScopedStates(scopeId);
|
||||
|
||||
const record = useRecoilValue(
|
||||
objectRecordMultiSelectFamilyState(objectRecordId),
|
||||
);
|
||||
|
||||
const objectRecordMultiSelectCheckedRecordsIds = useRecoilValue(
|
||||
objectRecordMultiSelectCheckedRecordsIdsState,
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
@ -50,12 +56,18 @@ export const MultipleObjectRecordSelectItem = ({
|
||||
onChange?.(objectRecordId);
|
||||
};
|
||||
|
||||
const { selected, recordIdentifier } = record;
|
||||
const { recordIdentifier } = record;
|
||||
|
||||
if (!isDefined(recordIdentifier)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selected = objectRecordMultiSelectCheckedRecordsIds.find(
|
||||
(checkedObjectRecord) => checkedObjectRecord === objectRecordId,
|
||||
)
|
||||
? true
|
||||
: false;
|
||||
|
||||
return (
|
||||
<StyledSelectableItem itemId={objectRecordId} key={objectRecordId + v4()}>
|
||||
<MenuItemMultiSelectAvatar
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { useRef } from 'react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { IconComponent, IconPlus } from 'twenty-ui';
|
||||
|
||||
import { SelectableMenuItemSelect } from '@/object-record/relation-picker/components/SelectableMenuItemSelect';
|
||||
import { SINGLE_ENTITY_SELECT_BASE_LIST } from '@/object-record/relation-picker/constants/SingleEntitySelectBaseList';
|
||||
import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton';
|
||||
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
||||
import { MenuItemSelect } from '@/ui/navigation/menu-item/components/MenuItemSelect';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
@ -32,6 +35,7 @@ export type SingleEntitySelectMenuItemsProps = {
|
||||
isAllEntitySelected?: boolean;
|
||||
isAllEntitySelectShown?: boolean;
|
||||
onAllEntitySelected?: () => void;
|
||||
hotkeyScope?: string;
|
||||
};
|
||||
|
||||
export const SingleEntitySelectMenuItems = ({
|
||||
@ -49,21 +53,68 @@ export const SingleEntitySelectMenuItems = ({
|
||||
isAllEntitySelected,
|
||||
isAllEntitySelectShown,
|
||||
onAllEntitySelected,
|
||||
hotkeyScope = RelationPickerHotkeyScope.RelationPicker,
|
||||
}: SingleEntitySelectMenuItemsProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const entitiesInDropdown = [selectedEntity, ...entitiesToSelect].filter(
|
||||
const createNewRecord = showCreateButton
|
||||
? {
|
||||
__typename: '',
|
||||
id: 'add-new',
|
||||
name: 'Add New',
|
||||
}
|
||||
: null;
|
||||
|
||||
const selectNone = emptyLabel
|
||||
? {
|
||||
__typename: '',
|
||||
id: 'select-none',
|
||||
name: emptyLabel,
|
||||
}
|
||||
: null;
|
||||
|
||||
const selectAll = isAllEntitySelectShown
|
||||
? {
|
||||
__typename: '',
|
||||
id: 'select-all',
|
||||
name: selectAllLabel,
|
||||
}
|
||||
: null;
|
||||
|
||||
const entitiesInDropdown = [
|
||||
selectAll,
|
||||
selectNone,
|
||||
selectedEntity,
|
||||
...entitiesToSelect,
|
||||
createNewRecord,
|
||||
].filter(
|
||||
(entity): entity is EntityForSelect =>
|
||||
isDefined(entity) && isNonEmptyString(entity.name),
|
||||
);
|
||||
|
||||
const { isSelectedItemIdSelector, handleResetSelectedPosition } =
|
||||
useSelectableList(SINGLE_ENTITY_SELECT_BASE_LIST);
|
||||
|
||||
const isSelectedAddNewButton = useRecoilValue(
|
||||
isSelectedItemIdSelector('add-new'),
|
||||
);
|
||||
|
||||
const isSelectedSelectNoneButton = useRecoilValue(
|
||||
isSelectedItemIdSelector('select-none'),
|
||||
);
|
||||
|
||||
const isSelectedSelectAllButton = useRecoilValue(
|
||||
isSelectedItemIdSelector('select-all'),
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
[Key.Escape],
|
||||
() => {
|
||||
handleResetSelectedPosition();
|
||||
onCancel?.();
|
||||
},
|
||||
RelationPickerHotkeyScope.RelationPicker,
|
||||
[onCancel],
|
||||
hotkeyScope,
|
||||
[onCancel, handleResetSelectedPosition],
|
||||
);
|
||||
|
||||
const selectableItemIds = entitiesInDropdown.map((entity) => entity.id);
|
||||
@ -71,66 +122,95 @@ export const SingleEntitySelectMenuItems = ({
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<SelectableList
|
||||
selectableListId="single-entity-select-base-list"
|
||||
selectableListId={SINGLE_ENTITY_SELECT_BASE_LIST}
|
||||
selectableItemIdArray={selectableItemIds}
|
||||
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
|
||||
hotkeyScope={hotkeyScope}
|
||||
onEnter={(itemId) => {
|
||||
if (showCreateButton === true) {
|
||||
if (itemId === 'add-new' && showCreateButton === true) {
|
||||
onCreate?.();
|
||||
} else {
|
||||
const entity = entitiesInDropdown.findIndex(
|
||||
const entityIndex = entitiesInDropdown.findIndex(
|
||||
(entity) => entity.id === itemId,
|
||||
);
|
||||
onEntitySelected(entitiesInDropdown[entity]);
|
||||
onEntitySelected(entitiesInDropdown[entityIndex]);
|
||||
}
|
||||
handleResetSelectedPosition();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{loading ? (
|
||||
<DropdownMenuSkeletonItem />
|
||||
) : entitiesInDropdown.length === 0 && !isAllEntitySelectShown ? (
|
||||
<MenuItem text="No result" />
|
||||
) : (
|
||||
<>
|
||||
{isAllEntitySelectShown &&
|
||||
selectAllLabel &&
|
||||
onAllEntitySelected && (
|
||||
<MenuItemSelect
|
||||
key="select-all"
|
||||
onClick={() => onAllEntitySelected()}
|
||||
LeftIcon={SelectAllIcon}
|
||||
text={selectAllLabel}
|
||||
selected={!!isAllEntitySelected}
|
||||
/>
|
||||
)}
|
||||
{emptyLabel && (
|
||||
<MenuItemSelect
|
||||
key="select-none"
|
||||
onClick={() => onEntitySelected()}
|
||||
LeftIcon={EmptyIcon}
|
||||
text={emptyLabel}
|
||||
selected={!selectedEntity}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{entitiesInDropdown?.map((entity) => (
|
||||
<SelectableMenuItemSelect
|
||||
key={entity.id}
|
||||
entity={entity}
|
||||
onEntitySelected={onEntitySelected}
|
||||
selectedEntity={selectedEntity}
|
||||
/>
|
||||
))}
|
||||
{showCreateButton && (
|
||||
<>
|
||||
<MenuItem text="No result" />
|
||||
{entitiesToSelect.length > 0 && <DropdownMenuSeparator />}
|
||||
<CreateNewButton
|
||||
key="add-new"
|
||||
onClick={onCreate}
|
||||
LeftIcon={IconPlus}
|
||||
text="Add New"
|
||||
hovered={isSelectedAddNewButton}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
entitiesInDropdown?.map((entity) => {
|
||||
switch (entity.id) {
|
||||
case 'add-new': {
|
||||
return (
|
||||
<>
|
||||
{entitiesToSelect.length > 0 && <DropdownMenuSeparator />}
|
||||
<CreateNewButton
|
||||
key={entity.id}
|
||||
onClick={onCreate}
|
||||
LeftIcon={IconPlus}
|
||||
text="Add New"
|
||||
hovered={isSelectedAddNewButton}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'select-none': {
|
||||
return (
|
||||
emptyLabel && (
|
||||
<MenuItemSelect
|
||||
key={entity.id}
|
||||
onClick={() => onEntitySelected()}
|
||||
LeftIcon={EmptyIcon}
|
||||
text={emptyLabel}
|
||||
selected={!selectedEntity}
|
||||
hovered={isSelectedSelectNoneButton}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
case 'select-all': {
|
||||
return (
|
||||
isAllEntitySelectShown &&
|
||||
selectAllLabel &&
|
||||
onAllEntitySelected && (
|
||||
<MenuItemSelect
|
||||
key={entity.id}
|
||||
onClick={() => onAllEntitySelected()}
|
||||
LeftIcon={SelectAllIcon}
|
||||
text={selectAllLabel}
|
||||
selected={!!isAllEntitySelected}
|
||||
hovered={isSelectedSelectAllButton}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<SelectableMenuItemSelect
|
||||
key={entity.id}
|
||||
entity={entity}
|
||||
onEntitySelected={onEntitySelected}
|
||||
selectedEntity={selectedEntity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</SelectableList>
|
||||
|
||||
@ -79,6 +79,7 @@ export const SingleEntitySelectMenuItemsWithSearch = ({
|
||||
? entities.selectedEntities[0]
|
||||
: undefined)
|
||||
}
|
||||
hotkeyScope={relationPickerScopeId}
|
||||
onCreate={onCreateWithInput}
|
||||
{...{
|
||||
EmptyIcon,
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export const SINGLE_ENTITY_SELECT_BASE_LIST = 'single-entity-select-base-list';
|
||||
Reference in New Issue
Block a user