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:
Aditya Pimpalkar
2024-08-02 19:12:46 +01:00
committed by GitHub
parent c5d95dc4c8
commit 1f5c25ac0a
17 changed files with 518 additions and 153 deletions

View File

@ -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();
}}
/>
);
})}

View File

@ -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

View File

@ -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>

View File

@ -79,6 +79,7 @@ export const SingleEntitySelectMenuItemsWithSearch = ({
? entities.selectedEntities[0]
: undefined)
}
hotkeyScope={relationPickerScopeId}
onCreate={onCreateWithInput}
{...{
EmptyIcon,

View File

@ -0,0 +1 @@
export const SINGLE_ENTITY_SELECT_BASE_LIST = 'single-entity-select-base-list';