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

@ -79,7 +79,10 @@ export const ObjectFilterDropdownFilterSelect = () => {
onClick={() => {
setFilterDefinitionUsedInDropdown(availableFilterDefinition);
if (availableFilterDefinition.type === 'RELATION') {
if (
availableFilterDefinition.type === 'RELATION' ||
availableFilterDefinition.type === 'SELECT'
) {
setHotkeyScope(RelationPickerHotkeyScope.RelationPicker);
}

View File

@ -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 (
<DropdownMenuItemsContainer hasMaxHeight>
{optionsInDropdown?.map((option) => (
<MenuItemMultiSelect
key={option.id}
selected={option.isSelected}
onSelectChange={(selected) =>
handleMultipleOptionSelectChange(option, selected)
}
text={option.label}
color={option.color}
className=""
/>
))}
<SelectableList
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
selectableItemIdArray={objectRecordsIds}
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
onEnter={(itemId) => {
const option = optionsInDropdown.find((option) => option.id === itemId);
if (isDefined(option)) {
handleMultipleOptionSelectChange(option, !option.isSelected);
}
}}
>
<DropdownMenuItemsContainer hasMaxHeight>
{optionsInDropdown?.map((option) => (
<MenuItemMultiSelect
key={option.id}
selected={option.isSelected}
isKeySelected={option.id === selectedItemId}
onSelectChange={(selected) =>
handleMultipleOptionSelectChange(option, selected)
}
text={option.label}
color={option.color}
className=""
/>
))}
</DropdownMenuItemsContainer>
{showNoResult && <MenuItem text="No result" />}
</DropdownMenuItemsContainer>
</SelectableList>
);
};

View File

@ -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 (
<MultipleRecordSelectDropdown
selectableListId="object-filter-record-select-id"
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
recordsToSelect={recordsToSelect}
filteredSelectedRecords={filteredSelectedRecords}
selectedRecords={selectedRecords}

View File

@ -1,12 +1,19 @@
import { useRef, useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField';
import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId';
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 { MenuItemMultiSelectTag } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectTag';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
@ -23,7 +30,15 @@ export type MultiSelectFieldInputProps = {
export const MultiSelectFieldInput = ({
onCancel,
}: MultiSelectFieldInputProps) => {
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<HTMLDivElement>(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 (
<StyledRelationPickerContainer ref={containerRef}>
<DropdownMenu data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) => setSearchFilter(event.currentTarget.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{optionsInDropDown.map((option) => {
return (
<MenuItemMultiSelectTag
key={option.value}
selected={fieldValues?.includes(option.value) || false}
text={option.label}
color={option.color}
onClick={() =>
persistField(formatNewSelectedOptions(option.value))
}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledRelationPickerContainer>
<SelectableList
selectableListId={MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
onEnter={(itemId) => {
const option = optionsInDropDown.find(
(option) => option.value === itemId,
);
if (isDefined(option)) {
persistField(formatNewSelectedOptions(option.value));
}
}}
>
<StyledRelationPickerContainer ref={containerRef}>
<DropdownMenu data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) => setSearchFilter(event.currentTarget.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{optionsInDropDown.map((option) => {
return (
<MenuItemMultiSelectTag
key={option.value}
selected={fieldValues?.includes(option.value) || false}
text={option.label}
color={option.color}
onClick={() =>
persistField(formatNewSelectedOptions(option.value))
}
isKeySelected={selectedItemId === option.value}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledRelationPickerContainer>
</SelectableList>
);
};

View File

@ -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<HTMLDivElement>(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 (
<StyledRelationPickerContainer ref={containerRef}>
<DropdownMenu data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) => setSearchFilter(event.currentTarget.value)}
autoFocus
/>
<DropdownMenuSeparator />
<SelectableList
selectableListId={SINGLE_ENTITY_SELECT_BASE_LIST}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
onEnter={(itemId) => {
const option = optionsInDropDown.find(
(option) => option.value === itemId,
);
if (isDefined(option)) {
onSubmit?.(() => persistField(option.value));
handleResetSelectedPosition();
}
}}
>
<StyledRelationPickerContainer ref={containerRef}>
<DropdownMenu data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) => setSearchFilter(event.currentTarget.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{fieldDefinition.metadata.isNullable && (
<MenuItemSelectTag
key={`No ${fieldDefinition.label}`}
selected={false}
text={`No ${fieldDefinition.label}`}
color="transparent"
variant="outline"
onClick={handleClearField}
/>
)}
{optionsInDropDown.map((option) => {
return (
<DropdownMenuItemsContainer hasMaxHeight>
{fieldDefinition.metadata.isNullable ?? (
<MenuItemSelectTag
key={option.value}
selected={option.value === fieldValue}
text={option.label}
color={option.color}
onClick={() => 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}`}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledRelationPickerContainer>
)}
{optionsInDropDown.map((option) => {
return (
<MenuItemSelectTag
key={option.value}
selected={option.value === fieldValue}
text={option.label}
color={option.color}
onClick={() => {
onSubmit?.(() => persistField(option.value));
handleResetSelectedPosition();
}}
isKeySelected={selectedItemId === option.value}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledRelationPickerContainer>
</SelectableList>
);
};

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';

View File

@ -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 (
<DropdownMenuItemsContainer hasMaxHeight>
{recordsInDropdown?.map((record) => (
<MenuItemMultiSelectAvatar
key={record.id}
selected={record.isSelected}
onSelectChange={(newCheckedValue) =>
handleRecordSelectChange(record, newCheckedValue)
}
avatar={
<Avatar
avatarUrl={record.avatarUrl}
placeholderColorSeed={record.id}
placeholder={record.name}
size="md"
type={record.avatarType ?? 'rounded'}
<SelectableList
selectableListId={selectableListId}
selectableItemIdArray={selectableItemIds}
hotkeyScope={hotkeyScope}
onEnter={(itemId) => {
const record = recordsInDropdown.findIndex(
(entity) => entity.id === itemId,
);
const recordIsSelectedInDropwdown = filteredSelectedRecords.find(
(entity) => entity.id === itemId,
);
handleRecordSelectChange(
recordsInDropdown[record],
!recordIsSelectedInDropwdown,
);
handleResetSelectedPosition();
}}
>
<DropdownMenuItemsContainer hasMaxHeight>
{recordsInDropdown?.map((record) => {
return (
<MenuItemMultiSelectAvatar
key={record.id}
selected={record.isSelected}
isKeySelected={record.id === selectedItemId}
onSelectChange={(newCheckedValue) => {
handleResetSelectedPosition();
handleRecordSelectChange(record, newCheckedValue);
}}
avatar={
<Avatar
avatarUrl={record.avatarUrl}
placeholderColorSeed={record.id}
placeholder={record.name}
size="md"
type={record.avatarType ?? 'rounded'}
/>
}
text={record.name}
/>
}
text={record.name}
/>
))}
{showNoResult && <MenuItem text="No result" />}
{loadingRecords && <DropdownMenuSkeletonItem />}
</DropdownMenuItemsContainer>
);
})}
{showNoResult && <MenuItem text="No result" />}
{loadingRecords && <DropdownMenuSkeletonItem />}
</DropdownMenuItemsContainer>
</SelectableList>
);
};

View File

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

View File

@ -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,
};
};

View File

@ -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 (
<StyledMenuItemBase className={className} onClick={handleOnClick}>
<StyledMenuItemBase
isKeySelected={isKeySelected}
className={className}
onClick={handleOnClick}
>
<StyledLeftContentWithCheckboxContainer>
<Checkbox checked={selected} />
{color ? (

View File

@ -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 (
<StyledMenuItemBase onClick={onClick} className={className}>
<StyledMenuItemBase
isKeySelected={isKeySelected}
onClick={onClick}
className={className}
>
<Checkbox
size={CheckboxSize.Small}
shape={CheckboxShape.Squared}

View File

@ -7,6 +7,7 @@ import { StyledMenuItemSelect } from './MenuItemSelect';
type MenuItemSelectTagProps = {
selected: boolean;
isKeySelected?: boolean;
className?: string;
onClick?: () => 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}
>
<StyledMenuItemLeftContent>
<Tag variant={variant} color={color} text={text} />

View File

@ -32,6 +32,9 @@ export const StyledMenuItemBase = styled.div<MenuItemBaseProps>`
padding: var(--vertical-padding) var(--horizontal-padding);
${({ theme, isKeySelected }) =>
isKeySelected ? `background: ${theme.background.transparent.light};` : ''}
${({ isHoverBackgroundDisabled }) =>
isHoverBackgroundDisabled ?? HOVER_BACKGROUND};