Feat/single entity select relation picker (#345)

* - Implemented recoil scoped state
- Implemented SingleEntitySelect
- Implemented keyboard shortcut up/down select

* Added useRecoilScopedValue

* Fix storybook

* Fix storybook

* Fix storybook

* Fix storybook

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2023-06-21 22:29:07 +02:00
committed by GitHub
parent 8a330b9746
commit e679f45615
23 changed files with 653 additions and 180 deletions
@@ -0,0 +1,105 @@
import { useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTheme } from '@emotion/react';
import { IconPlus } from '@tabler/icons-react';
import { EntityForSelect } from '@/relation-picker/types/EntityForSelect';
import { DropdownMenu } from '@/ui/components/menu/DropdownMenu';
import { DropdownMenuButton } from '@/ui/components/menu/DropdownMenuButton';
import { DropdownMenuItem } from '@/ui/components/menu/DropdownMenuItem';
import { DropdownMenuItemContainer } from '@/ui/components/menu/DropdownMenuItemContainer';
import { DropdownMenuSearch } from '@/ui/components/menu/DropdownMenuSearch';
import { DropdownMenuSelectableItem } from '@/ui/components/menu/DropdownMenuSelectableItem';
import { DropdownMenuSeparator } from '@/ui/components/menu/DropdownMenuSeparator';
import { Avatar } from '@/users/components/Avatar';
import { isDefined } from '@/utils/type-guards/isDefined';
import { useEntitySelectLogic } from '../hooks/useEntitySelectLogic';
export type EntitiesForSingleEntitySelect<
CustomEntityForSelect extends EntityForSelect,
> = {
selectedEntity: CustomEntityForSelect;
entitiesToSelect: CustomEntityForSelect[];
};
export function SingleEntitySelect<
CustomEntityForSelect extends EntityForSelect,
>({
entities,
onEntitySelected,
onCreate,
}: {
onCreate?: () => void;
entities: EntitiesForSingleEntitySelect<CustomEntityForSelect>;
onEntitySelected: (entity: CustomEntityForSelect) => void;
}) {
const theme = useTheme();
const containerRef = useRef<HTMLDivElement>(null);
const entitiesInDropdown = isDefined(entities.selectedEntity)
? [entities.selectedEntity, ...(entities.entitiesToSelect ?? [])]
: entities.entitiesToSelect ?? [];
const { hoveredIndex, searchFilter, handleSearchFilterChange } =
useEntitySelectLogic({
entities: entitiesInDropdown,
containerRef,
});
useHotkeys(
'enter',
() => {
onEntitySelected(entitiesInDropdown[hoveredIndex]);
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[entitiesInDropdown, hoveredIndex, onEntitySelected],
);
const showCreateButton = isDefined(onCreate) && searchFilter !== '';
return (
<DropdownMenu>
<DropdownMenuSearch
value={searchFilter}
onChange={handleSearchFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
{showCreateButton && (
<>
<DropdownMenuItemContainer>
<DropdownMenuButton onClick={onCreate}>
<IconPlus size={theme.iconSizeMedium} />
Create new
</DropdownMenuButton>
</DropdownMenuItemContainer>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItemContainer ref={containerRef}>
{entitiesInDropdown?.map((entity, index) => (
<DropdownMenuSelectableItem
key={entity.id}
selected={entities.selectedEntity?.id === entity.id}
hovered={hoveredIndex === index}
onClick={() => onEntitySelected(entity)}
>
<Avatar
avatarUrl={entity.avatarUrl}
placeholder={entity.name}
size={16}
type={entity.avatarType ?? 'rounded'}
/>
{entity.name}
</DropdownMenuSelectableItem>
))}
{entitiesInDropdown?.length === 0 && (
<DropdownMenuItem>No result</DropdownMenuItem>
)}
</DropdownMenuItemContainer>
</DropdownMenu>
);
}
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { debounce } from 'lodash';
import scrollIntoView from 'scroll-into-view';
import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState';
import { relationPickerSearchFilterScopedState } from '../states/relationPickerSearchFilterScopedState';
import { EntityForSelect } from '../types/EntityForSelect';
export function useEntitySelectLogic<
CustomEntityForSelect extends EntityForSelect,
>({
containerRef,
entities,
}: {
entities: CustomEntityForSelect[];
containerRef: React.RefObject<HTMLDivElement>;
}) {
const [hoveredIndex, setHoveredIndex] = useState(0);
const [searchFilter, setSearchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
const debouncedSetSearchFilter = debounce(setSearchFilter, 100, {
leading: true,
});
function handleSearchFilterChange(
event: React.ChangeEvent<HTMLInputElement>,
) {
debouncedSetSearchFilter(event.currentTarget.value);
setHoveredIndex(0);
}
useHotkeys(
'down',
() => {
setHoveredIndex((prevSelectedIndex) =>
Math.min(prevSelectedIndex + 1, (entities?.length ?? 0) - 1),
);
const currentHoveredRef = containerRef.current?.children[
hoveredIndex
] as HTMLElement;
if (currentHoveredRef) {
scrollIntoView(currentHoveredRef, {
align: {
top: 0.275,
},
isScrollable: (target) => {
return target === containerRef.current;
},
time: 0,
});
}
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
[setHoveredIndex, entities],
);
useHotkeys(
'up',
() => {
setHoveredIndex((prevSelectedIndex) =>
Math.max(prevSelectedIndex - 1, 0),
);
const currentHoveredRef = containerRef.current?.children[
hoveredIndex
] as HTMLElement;
if (currentHoveredRef) {
scrollIntoView(currentHoveredRef, {
align: {
top: 0.5,
},
isScrollable: (target) => {
return target === containerRef.current;
},
time: 0,
});
}
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
[setHoveredIndex, entities],
);
return {
hoveredIndex,
searchFilter,
handleSearchFilterChange,
};
}
@@ -0,0 +1,8 @@
import { atomFamily } from 'recoil';
export const relationPickerSearchFilterScopedState = atomFamily<string, string>(
{
key: 'relationPickerSearchFilterScopedState',
default: '',
},
);