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:
@@ -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: '',
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user