From 1f5c25ac0a7bc206b46c34466c23bb03ae5c4da1 Mon Sep 17 00:00:00 2001 From: Aditya Pimpalkar Date: Fri, 2 Aug 2024 19:12:46 +0100 Subject: [PATCH] fix: navigate with arrow keys in select/multi-select (#5983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes: #4977 https://github.com/twentyhq/twenty/assets/13139771/8121814c-9a8a-4a8d-9290-1aebe145220f --------- Co-authored-by: Félix Malfait Co-authored-by: Lucas Bordeau --- .../ObjectFilterDropdownFilterSelect.tsx | 5 +- .../ObjectFilterDropdownOptionSelect.tsx | 72 ++++++-- .../ObjectFilterDropdownRecordSelect.tsx | 3 + .../components/MultiSelectFieldInput.tsx | 95 +++++++--- .../input/components/SelectFieldInput.tsx | 113 ++++++++---- .../components/MultiRecordSelect.tsx | 47 ++++- .../MultipleObjectRecordSelectItem.tsx | 18 +- .../SingleEntitySelectMenuItems.tsx | 164 +++++++++++++----- .../SingleEntitySelectMenuItemsWithSearch.tsx | 1 + .../constants/SingleEntitySelectBaseList.ts | 1 + .../MultipleRecordSelectDropdown.tsx | 99 ++++++++--- .../layout/dropdown/components/Dropdown.tsx | 10 +- .../hooks/useSelectableList.ts | 21 ++- .../components/MenuItemMultiSelect.tsx | 8 +- .../components/MenuItemMultiSelectTag.tsx | 8 +- .../components/MenuItemSelectTag.tsx | 3 + .../components/StyledMenuItemBase.tsx | 3 + 17 files changed, 518 insertions(+), 153 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/relation-picker/constants/SingleEntitySelectBaseList.ts diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx index 3e338c957..bf123401f 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx @@ -79,7 +79,10 @@ export const ObjectFilterDropdownFilterSelect = () => { onClick={() => { setFilterDefinitionUsedInDropdown(availableFilterDefinition); - if (availableFilterDefinition.type === 'RELATION') { + if ( + availableFilterDefinition.type === 'RELATION' || + availableFilterDefinition.type === 'SELECT' + ) { setHotkeyScope(RelationPickerHotkeyScope.RelationPicker); } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx index 6180ff3d3..9ced7ee64 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx @@ -1,13 +1,21 @@ import { useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; +import { Key } from 'ts-key-enum'; import { v4 } from 'uuid'; import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { useOptionsForSelect } from '@/object-record/object-filter-dropdown/hooks/useOptionsForSelect'; +import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId'; +import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { MenuItemMultiSelect } from '@/ui/navigation/menu-item/components/MenuItemMultiSelect'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { isDefined } from '~/utils/isDefined'; export const EMPTY_FILTER_VALUE = ''; @@ -27,6 +35,17 @@ export const ObjectFilterDropdownOptionSelect = () => { selectFilter, } = useFilterDropdown(); + const { closeDropdown } = useDropdown(); + + const { selectedItemIdState } = useSelectableListStates({ + selectableListScopeId: MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID, + }); + + const { handleResetSelectedPosition } = useSelectableList( + MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID, + ); + + const selectedItemId = useRecoilValue(selectedItemIdState); const filterDefinitionUsedInDropdown = useRecoilValue( filterDefinitionUsedInDropdownState, ); @@ -67,6 +86,16 @@ export const ObjectFilterDropdownOptionSelect = () => { } }, [objectFilterDropdownSelectedOptionValues, selectOptions]); + useScopedHotkeys( + [Key.Escape], + () => { + closeDropdown(); + handleResetSelectedPosition(); + }, + RelationPickerHotkeyScope.RelationPicker, + [closeDropdown, handleResetSelectedPosition], + ); + const handleMultipleOptionSelectChange = ( optionChanged: SelectOptionForFilter, isSelected: boolean, @@ -108,6 +137,7 @@ export const ObjectFilterDropdownOptionSelect = () => { value: newFilterValue, }); } + handleResetSelectedPosition(); }; const optionsInDropdown = selectableOptions?.filter((option) => @@ -117,22 +147,36 @@ export const ObjectFilterDropdownOptionSelect = () => { ); const showNoResult = optionsInDropdown?.length === 0; + const objectRecordsIds = optionsInDropdown.map((option) => option.id); return ( - - {optionsInDropdown?.map((option) => ( - - handleMultipleOptionSelectChange(option, selected) - } - text={option.label} - color={option.color} - className="" - /> - ))} + { + const option = optionsInDropdown.find((option) => option.id === itemId); + if (isDefined(option)) { + handleMultipleOptionSelectChange(option, !option.isSelected); + } + }} + > + + {optionsInDropdown?.map((option) => ( + + handleMultipleOptionSelectChange(option, selected) + } + text={option.label} + color={option.color} + className="" + /> + ))} + {showNoResult && } - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx index a5cf81854..7546dc51c 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx @@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil'; import { v4 } from 'uuid'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; +import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; import { MultipleRecordSelectDropdown } from '@/object-record/select/components/MultipleRecordSelectDropdown'; import { useRecordsForSelect } from '@/object-record/select/hooks/useRecordsForSelect'; import { SelectableRecord } from '@/object-record/select/types/SelectableRecord'; @@ -131,6 +132,8 @@ export const ObjectFilterDropdownRecordSelect = ({ return ( { - const { persistField, fieldDefinition, fieldValues } = useMultiSelectField(); + const { selectedItemIdState } = useSelectableListStates({ + selectableListScopeId: MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID, + }); + const { handleResetSelectedPosition } = useSelectableList( + MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID, + ); + const { persistField, fieldDefinition, fieldValues, hotkeyScope } = + useMultiSelectField(); + const selectedItemId = useRecoilValue(selectedItemIdState); const [searchFilter, setSearchFilter] = useState(''); const containerRef = useRef(null); @@ -46,6 +61,16 @@ export const MultiSelectFieldInput = ({ } }; + useScopedHotkeys( + Key.Escape, + () => { + onCancel?.(); + handleResetSelectedPosition(); + }, + hotkeyScope, + [onCancel, handleResetSelectedPosition], + ); + useListenClickOutside({ refs: [containerRef], callback: (event) => { @@ -58,34 +83,52 @@ export const MultiSelectFieldInput = ({ if (weAreNotInAnHTMLInput && isDefined(onCancel)) { onCancel(); } + handleResetSelectedPosition(); }, }); + const optionIds = optionsInDropDown.map((option) => option.value); + return ( - - - setSearchFilter(event.currentTarget.value)} - autoFocus - /> - - - {optionsInDropDown.map((option) => { - return ( - - persistField(formatNewSelectedOptions(option.value)) - } - /> - ); - })} - - - + { + const option = optionsInDropDown.find( + (option) => option.value === itemId, + ); + if (isDefined(option)) { + persistField(formatNewSelectedOptions(option.value)); + } + }} + > + + + setSearchFilter(event.currentTarget.value)} + autoFocus + /> + + + {optionsInDropDown.map((option) => { + return ( + + persistField(formatNewSelectedOptions(option.value)) + } + isKeySelected={selectedItemId === option.value} + /> + ); + })} + + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx index a1d9a8631..7bc5c81ee 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx @@ -1,14 +1,19 @@ import styled from '@emotion/styled'; import { useRef, useState } from 'react'; +import { useRecoilValue } from 'recoil'; import { Key } from 'ts-key-enum'; import { useClearField } from '@/object-record/record-field/hooks/useClearField'; import { useSelectField } from '@/object-record/record-field/meta-types/hooks/useSelectField'; import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; +import { SINGLE_ENTITY_SELECT_BASE_LIST } from '@/object-record/relation-picker/constants/SingleEntitySelectBaseList'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { MenuItemSelectTag } from '@/ui/navigation/menu-item/components/MenuItemSelectTag'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; @@ -31,8 +36,15 @@ export const SelectFieldInput = ({ }: SelectFieldInputProps) => { const { persistField, fieldDefinition, fieldValue, hotkeyScope } = useSelectField(); + const { selectedItemIdState } = useSelectableListStates({ + selectableListScopeId: SINGLE_ENTITY_SELECT_BASE_LIST, + }); + const { handleResetSelectedPosition } = useSelectableList( + SINGLE_ENTITY_SELECT_BASE_LIST, + ); const clearField = useClearField(); + const selectedItemId = useRecoilValue(selectedItemIdState); const [searchFilter, setSearchFilter] = useState(''); const containerRef = useRef(null); @@ -69,10 +81,21 @@ export const SelectFieldInput = ({ ); if (weAreNotInAnHTMLInput && isDefined(onCancel)) { onCancel(); + handleResetSelectedPosition(); } }, }); + useScopedHotkeys( + Key.Escape, + () => { + onCancel?.(); + handleResetSelectedPosition(); + }, + hotkeyScope, + [onCancel, handleResetSelectedPosition], + ); + useScopedHotkeys( Key.Enter, () => { @@ -83,45 +106,71 @@ export const SelectFieldInput = ({ if (isDefined(selectedOption)) { onSubmit?.(() => persistField(selectedOption.value)); } + handleResetSelectedPosition(); }, hotkeyScope, ); + const optionIds = [ + `No ${fieldDefinition.label}`, + ...optionsInDropDown.map((option) => option.value), + ]; + return ( - - - setSearchFilter(event.currentTarget.value)} - autoFocus - /> - + { + const option = optionsInDropDown.find( + (option) => option.value === itemId, + ); + if (isDefined(option)) { + onSubmit?.(() => persistField(option.value)); + handleResetSelectedPosition(); + } + }} + > + + + setSearchFilter(event.currentTarget.value)} + autoFocus + /> + - - {fieldDefinition.metadata.isNullable && ( - - )} - - {optionsInDropDown.map((option) => { - return ( + + {fieldDefinition.metadata.isNullable ?? ( onSubmit?.(() => persistField(option.value))} + key={`No ${fieldDefinition.label}`} + selected={false} + text={`No ${fieldDefinition.label}`} + color="transparent" + variant="outline" + onClick={handleClearField} + isKeySelected={selectedItemId === `No ${fieldDefinition.label}`} /> - ); - })} - - - + )} + + {optionsInDropDown.map((option) => { + return ( + { + onSubmit?.(() => persistField(option.value)); + handleResetSelectedPosition(); + }} + isKeySelected={selectedItemId === option.value} + /> + ); + })} + + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx index c4acbcb1f..0730c0505 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultiRecordSelect.tsx @@ -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(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 = ({ { + onChange?.(selectedId); + handleResetSelectedPosition(); + }} > {objectRecordsIdsMultiSelect?.map((recordId) => { return ( { + onChange?.(recordId); + handleResetSelectedPosition(); + }} /> ); })} diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx index fc4061cfc..ee4885258 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelectItem.tsx @@ -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 ( void; + hotkeyScope?: string; }; export const SingleEntitySelectMenuItems = ({ @@ -49,21 +53,68 @@ export const SingleEntitySelectMenuItems = ({ isAllEntitySelected, isAllEntitySelectShown, onAllEntitySelected, + hotkeyScope = RelationPickerHotkeyScope.RelationPicker, }: SingleEntitySelectMenuItemsProps) => { const containerRef = useRef(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 (
{ - 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(); }} > {loading ? ( ) : entitiesInDropdown.length === 0 && !isAllEntitySelectShown ? ( - - ) : ( - <> - {isAllEntitySelectShown && - selectAllLabel && - onAllEntitySelected && ( - onAllEntitySelected()} - LeftIcon={SelectAllIcon} - text={selectAllLabel} - selected={!!isAllEntitySelected} - /> - )} - {emptyLabel && ( - onEntitySelected()} - LeftIcon={EmptyIcon} - text={emptyLabel} - selected={!selectedEntity} - /> - )} - - )} - {entitiesInDropdown?.map((entity) => ( - - ))} - {showCreateButton && ( <> + {entitiesToSelect.length > 0 && } + ) : ( + entitiesInDropdown?.map((entity) => { + switch (entity.id) { + case 'add-new': { + return ( + <> + {entitiesToSelect.length > 0 && } + + + ); + } + case 'select-none': { + return ( + emptyLabel && ( + onEntitySelected()} + LeftIcon={EmptyIcon} + text={emptyLabel} + selected={!selectedEntity} + hovered={isSelectedSelectNoneButton} + /> + ) + ); + } + case 'select-all': { + return ( + isAllEntitySelectShown && + selectAllLabel && + onAllEntitySelected && ( + onAllEntitySelected()} + LeftIcon={SelectAllIcon} + text={selectAllLabel} + selected={!!isAllEntitySelected} + hovered={isSelectedSelectAllButton} + /> + ) + ); + } + default: { + return ( + + ); + } + } + }) )} diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx index 2c609e0e3..74c054132 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx @@ -79,6 +79,7 @@ export const SingleEntitySelectMenuItemsWithSearch = ({ ? entities.selectedEntities[0] : undefined) } + hotkeyScope={relationPickerScopeId} onCreate={onCreateWithInput} {...{ EmptyIcon, diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/constants/SingleEntitySelectBaseList.ts b/packages/twenty-front/src/modules/object-record/relation-picker/constants/SingleEntitySelectBaseList.ts new file mode 100644 index 000000000..3aead1418 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/constants/SingleEntitySelectBaseList.ts @@ -0,0 +1 @@ +export const SINGLE_ENTITY_SELECT_BASE_LIST = 'single-entity-select-base-list'; diff --git a/packages/twenty-front/src/modules/object-record/select/components/MultipleRecordSelectDropdown.tsx b/packages/twenty-front/src/modules/object-record/select/components/MultipleRecordSelectDropdown.tsx index 86e9ef5aa..e8a286ad4 100644 --- a/packages/twenty-front/src/modules/object-record/select/components/MultipleRecordSelectDropdown.tsx +++ b/packages/twenty-front/src/modules/object-record/select/components/MultipleRecordSelectDropdown.tsx @@ -1,19 +1,30 @@ import { useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { Key } from 'ts-key-enum'; import { Avatar } from 'twenty-ui'; import { SelectableRecord } from '@/object-record/select/types/SelectableRecord'; import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { MenuItemMultiSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectAvatar'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; export const MultipleRecordSelectDropdown = ({ + selectableListId, + hotkeyScope, recordsToSelect, loadingRecords, filteredSelectedRecords, onChange, searchFilter, }: { + selectableListId: string; + hotkeyScope: string; recordsToSelect: SelectableRecord[]; filteredSelectedRecords: SelectableRecord[]; selectedRecords: SelectableRecord[]; @@ -24,6 +35,15 @@ export const MultipleRecordSelectDropdown = ({ ) => void; loadingRecords: boolean; }) => { + const { closeDropdown } = useDropdown(); + const { selectedItemIdState } = useSelectableListStates({ + selectableListScopeId: selectableListId, + }); + + const { handleResetSelectedPosition } = useSelectableList(selectableListId); + + const selectedItemId = useRecoilValue(selectedItemIdState); + const handleRecordSelectChange = ( recordToSelect: SelectableRecord, newSelectedValue: boolean, @@ -51,35 +71,70 @@ export const MultipleRecordSelectDropdown = ({ } }, [recordsToSelect, filteredSelectedRecords, loadingRecords]); + useScopedHotkeys( + [Key.Escape], + () => { + closeDropdown(); + handleResetSelectedPosition(); + }, + hotkeyScope, + [closeDropdown, handleResetSelectedPosition], + ); + const showNoResult = recordsToSelect?.length === 0 && searchFilter !== '' && filteredSelectedRecords?.length === 0 && !loadingRecords; + const selectableItemIds = recordsInDropdown.map((record) => record.id); + return ( - - {recordsInDropdown?.map((record) => ( - - handleRecordSelectChange(record, newCheckedValue) - } - avatar={ - { + const record = recordsInDropdown.findIndex( + (entity) => entity.id === itemId, + ); + const recordIsSelectedInDropwdown = filteredSelectedRecords.find( + (entity) => entity.id === itemId, + ); + handleRecordSelectChange( + recordsInDropdown[record], + !recordIsSelectedInDropwdown, + ); + handleResetSelectedPosition(); + }} + > + + {recordsInDropdown?.map((record) => { + return ( + { + handleResetSelectedPosition(); + handleRecordSelectChange(record, newCheckedValue); + }} + avatar={ + + } + text={record.name} /> - } - text={record.name} - /> - ))} - {showNoResult && } - {loadingRecords && } - + ); + })} + {showNoResult && } + {loadingRecords && } + + ); }; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx index 14ba36441..ed24815dc 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx @@ -10,7 +10,9 @@ import { useRef } from 'react'; import { Keys } from 'react-hotkeys-hook'; import { Key } from 'ts-key-enum'; +import { SINGLE_ENTITY_SELECT_BASE_LIST } from '@/object-record/relation-picker/constants/SingleEntitySelectBaseList'; import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { HotkeyEffect } from '@/ui/utilities/hotkey/components/HotkeyEffect'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; @@ -66,6 +68,10 @@ export const Dropdown = ({ const { isDropdownOpen, toggleDropdown, closeDropdown, dropdownWidth } = useDropdown(dropdownId); + + const { handleResetSelectedPosition } = useSelectableList( + SINGLE_ENTITY_SELECT_BASE_LIST, + ); const offsetMiddlewares = []; if (isDefined(dropdownOffset.x)) { @@ -94,6 +100,7 @@ export const Dropdown = ({ if (isDropdownOpen) { closeDropdown(); + handleResetSelectedPosition(); } }, }); @@ -107,9 +114,10 @@ export const Dropdown = ({ [Key.Escape], () => { closeDropdown(); + handleResetSelectedPosition(); }, dropdownHotkeyScope.scope, - [closeDropdown], + [closeDropdown, handleResetSelectedPosition], ); return ( diff --git a/packages/twenty-front/src/modules/ui/layout/selectable-list/hooks/useSelectableList.ts b/packages/twenty-front/src/modules/ui/layout/selectable-list/hooks/useSelectableList.ts index 37098d3c9..89518de5d 100644 --- a/packages/twenty-front/src/modules/ui/layout/selectable-list/hooks/useSelectableList.ts +++ b/packages/twenty-front/src/modules/ui/layout/selectable-list/hooks/useSelectableList.ts @@ -1,6 +1,12 @@ -import { useResetRecoilState, useSetRecoilState } from 'recoil'; +import { + useRecoilCallback, + useResetRecoilState, + useSetRecoilState, +} from 'recoil'; import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates'; +import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; +import { isDefined } from '~/utils/isDefined'; export const useSelectableList = (selectableListId?: string) => { const { @@ -24,6 +30,18 @@ export const useSelectableList = (selectableListId?: string) => { resetSelectedItemIdState(); }; + const handleResetSelectedPosition = useRecoilCallback( + ({ snapshot, set }) => + () => { + const selectedItemId = getSnapshotValue(snapshot, selectedItemIdState); + if (isDefined(selectedItemId)) { + set(selectedItemIdState, null); + set(isSelectedItemIdSelector(selectedItemId), false); + } + }, + [selectedItemIdState, isSelectedItemIdSelector], + ); + return { selectableListId: scopeId, @@ -31,5 +49,6 @@ export const useSelectableList = (selectableListId?: string) => { isSelectedItemIdSelector, setSelectableListOnEnter, resetSelectedItem, + handleResetSelectedPosition, }; }; diff --git a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelect.tsx b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelect.tsx index dafaeae1d..83400066d 100644 --- a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelect.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelect.tsx @@ -17,6 +17,7 @@ type MenuItemMultiSelectProps = { color?: ThemeColor; LeftIcon?: IconComponent; selected: boolean; + isKeySelected?: boolean; text: string; className: string; onSelectChange?: (selected: boolean) => void; @@ -27,6 +28,7 @@ export const MenuItemMultiSelect = ({ LeftIcon, text, selected, + isKeySelected, className, onSelectChange, }: MenuItemMultiSelectProps) => { @@ -35,7 +37,11 @@ export const MenuItemMultiSelect = ({ }; return ( - + {color ? ( diff --git a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx index e1c941c45..8c8d94dc7 100644 --- a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx @@ -14,6 +14,7 @@ import { type MenuItemMultiSelectTagProps = { selected: boolean; className?: string; + isKeySelected?: boolean; onClick?: () => void; color: ThemeColor; text: string; @@ -24,10 +25,15 @@ export const MenuItemMultiSelectTag = ({ selected, className, onClick, + isKeySelected, text, }: MenuItemMultiSelectTagProps) => { return ( - + void; color: ThemeColor | 'transparent'; @@ -17,6 +18,7 @@ type MenuItemSelectTagProps = { export const MenuItemSelectTag = ({ color, selected, + isKeySelected, className, onClick, text, @@ -29,6 +31,7 @@ export const MenuItemSelectTag = ({ onClick={onClick} className={className} selected={selected} + isKeySelected={isKeySelected} > diff --git a/packages/twenty-front/src/modules/ui/navigation/menu-item/internals/components/StyledMenuItemBase.tsx b/packages/twenty-front/src/modules/ui/navigation/menu-item/internals/components/StyledMenuItemBase.tsx index a2489def3..f361efc64 100644 --- a/packages/twenty-front/src/modules/ui/navigation/menu-item/internals/components/StyledMenuItemBase.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/menu-item/internals/components/StyledMenuItemBase.tsx @@ -32,6 +32,9 @@ export const StyledMenuItemBase = styled.div` padding: var(--vertical-padding) var(--horizontal-padding); + ${({ theme, isKeySelected }) => + isKeySelected ? `background: ${theme.background.transparent.light};` : ''} + ${({ isHoverBackgroundDisabled }) => isHoverBackgroundDisabled ?? HOVER_BACKGROUND};