Favorites Drag and Drop Implementation (#8979)
Adds drag and drop functionality for favorites management, allowing users to: - Move favorites between folders - Move favorites from folders to orphan section - Move orphan favorites into folders Known Issues: Drop detection at folder boundaries requires spacing workaround
This commit is contained in:
@ -1,14 +1,14 @@
|
||||
import { FavoriteFolderNavigationDrawerItemDropdown } from '@/favorites/components/FavoriteFolderNavigationDrawerItemDropdown';
|
||||
import { FavoriteIcon } from '@/favorites/components/FavoriteIcon';
|
||||
import { FavoritesDroppable } from '@/favorites/components/FavoritesDroppable';
|
||||
import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext';
|
||||
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
|
||||
import { useDeleteFavoriteFolder } from '@/favorites/hooks/useDeleteFavoriteFolder';
|
||||
import { useRenameFavoriteFolder } from '@/favorites/hooks/useRenameFavoriteFolder';
|
||||
import { useReorderFavorite } from '@/favorites/hooks/useReorderFavorite';
|
||||
import { activeFavoriteFolderIdState } from '@/favorites/states/activeFavoriteFolderIdState';
|
||||
import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite';
|
||||
import { ProcessedFavorite } from '@/favorites/utils/sortFavorites';
|
||||
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
|
||||
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
|
||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { NavigationDrawerInput } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerInput';
|
||||
@ -16,8 +16,8 @@ import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/componen
|
||||
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
|
||||
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
|
||||
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
|
||||
import { DragStart, DropResult, ResponderProvided } from '@hello-pangea/dnd';
|
||||
import { useState } from 'react';
|
||||
import { Droppable } from '@hello-pangea/dnd';
|
||||
import { useContext, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useRecoilState } from 'recoil';
|
||||
@ -43,8 +43,7 @@ export const CurrentWorkspaceMemberFavorites = ({
|
||||
}: CurrentWorkspaceMemberFavoritesProps) => {
|
||||
const currentPath = useLocation().pathname;
|
||||
const currentViewPath = useLocation().pathname + useLocation().search;
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const { isDragging } = useContext(FavoritesDragContext);
|
||||
const [isFavoriteFolderRenaming, setIsFavoriteFolderRenaming] =
|
||||
useState(false);
|
||||
const [favoriteFolderName, setFavoriteFolderName] = useState(
|
||||
@ -69,7 +68,6 @@ export const CurrentWorkspaceMemberFavorites = ({
|
||||
const selectedFavoriteIndex = folder.favorites.findIndex((favorite) =>
|
||||
isLocationMatchingFavorite(currentPath, currentViewPath, favorite),
|
||||
);
|
||||
const { handleReorderFavorite } = useReorderFavorite();
|
||||
|
||||
const { deleteFavorite } = useDeleteFavorite();
|
||||
|
||||
@ -115,15 +113,6 @@ export const CurrentWorkspaceMemberFavorites = ({
|
||||
setIsDeleteModalOpen(false);
|
||||
};
|
||||
|
||||
const handleDragStart = (_: DragStart) => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||
setIsDragging(false);
|
||||
handleReorderFavorite(result, provided);
|
||||
};
|
||||
|
||||
const rightOptions = (
|
||||
<FavoriteFolderNavigationDrawerItemDropdown
|
||||
folderId={folder.folderId}
|
||||
@ -150,23 +139,31 @@ export const CurrentWorkspaceMemberFavorites = ({
|
||||
hotkeyScope="favorites-folder-input"
|
||||
/>
|
||||
) : (
|
||||
<NavigationDrawerItem
|
||||
key={folder.folderId}
|
||||
label={folder.folderName}
|
||||
Icon={isOpen ? IconFolderOpen : IconFolder}
|
||||
onClick={handleToggle}
|
||||
rightOptions={rightOptions}
|
||||
className="navigation-drawer-item"
|
||||
active={isFavoriteFolderEditDropdownOpen}
|
||||
/>
|
||||
<FavoritesDroppable droppableId={`folder-header-${folder.folderId}`}>
|
||||
<NavigationDrawerItem
|
||||
label={folder.folderName}
|
||||
Icon={isOpen ? IconFolderOpen : IconFolder}
|
||||
onClick={handleToggle}
|
||||
rightOptions={rightOptions}
|
||||
className="navigation-drawer-item"
|
||||
active={isFavoriteFolderEditDropdownOpen}
|
||||
/>
|
||||
</FavoritesDroppable>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<DraggableList
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
draggableItems={
|
||||
<>
|
||||
<Droppable droppableId={`folder-${folder.folderId}`}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...provided.droppableProps}
|
||||
style={{
|
||||
marginBottom: 15,
|
||||
}}
|
||||
// TODO: (Drag Drop Bug) Adding bottom margin to ensure drag-to-last-position works. Need to find better solution that doesn't affect spacing.
|
||||
// Issue: Without margin, dragging to last position triggers next folder drop area
|
||||
>
|
||||
{folder.favorites.map((favorite, index) => (
|
||||
<DraggableItem
|
||||
key={favorite.id}
|
||||
@ -175,7 +172,6 @@ export const CurrentWorkspaceMemberFavorites = ({
|
||||
isInsideScrollableContainer
|
||||
itemComponent={
|
||||
<NavigationDrawerSubItem
|
||||
key={favorite.id}
|
||||
label={favorite.labelIdentifier}
|
||||
Icon={() => <FavoriteIcon favorite={favorite} />}
|
||||
to={favorite.link}
|
||||
@ -197,9 +193,10 @@ export const CurrentWorkspaceMemberFavorites = ({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
)}
|
||||
</NavigationDrawerItemsCollapsableContainer>
|
||||
|
||||
|
||||
@ -1,47 +1,23 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import {
|
||||
IconFolderPlus,
|
||||
IconHeartOff,
|
||||
LightIconButton,
|
||||
isDefined,
|
||||
} from 'twenty-ui';
|
||||
import { IconFolderPlus, LightIconButton, isDefined } from 'twenty-ui';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { FavoriteIcon } from '@/favorites/components/FavoriteIcon';
|
||||
import { CurrentWorkspaceMemberOrphanFavorites } from '@/favorites/components/CurrentWorkspaceMemberOrphanFavorites';
|
||||
import { FavoritesDragProvider } from '@/favorites/components/FavoritesDragProvider';
|
||||
import { FavoriteFolders } from '@/favorites/components/FavoritesFolders';
|
||||
import { FavoritesSkeletonLoader } from '@/favorites/components/FavoritesSkeletonLoader';
|
||||
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
|
||||
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
||||
import { useReorderFavorite } from '@/favorites/hooks/useReorderFavorite';
|
||||
import { isFavoriteFolderCreatingState } from '@/favorites/states/isFavoriteFolderCreatingState';
|
||||
import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite';
|
||||
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
|
||||
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
|
||||
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
|
||||
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
|
||||
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
|
||||
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
|
||||
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
|
||||
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import styled from '@emotion/styled';
|
||||
import { DragStart, DropResult, ResponderProvided } from '@hello-pangea/dnd';
|
||||
import { useState } from 'react';
|
||||
|
||||
const StyledOrphanFavoritesContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.betweenSiblingsGap};
|
||||
`;
|
||||
|
||||
export const CurrentWorkspaceMemberFavoritesFolders = () => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const currentPath = useLocation().pathname;
|
||||
const currentViewPath = useLocation().pathname + useLocation().search;
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const { sortedFavorites: favorites } = useFavorites();
|
||||
const { deleteFavorite } = useDeleteFavorite();
|
||||
const { handleReorderFavorite } = useReorderFavorite();
|
||||
const [isFavoriteFolderCreating, setIsFavoriteFolderCreating] =
|
||||
useRecoilState(isFavoriteFolderCreatingState);
|
||||
|
||||
@ -62,15 +38,6 @@ export const CurrentWorkspaceMemberFavoritesFolders = () => {
|
||||
setIsFavoriteFolderCreating((current) => !current);
|
||||
};
|
||||
|
||||
const handleDragStart = (_: DragStart) => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||
setIsDragging(false);
|
||||
handleReorderFavorite(result, provided);
|
||||
};
|
||||
|
||||
const shouldDisplayFavoritesWithFeatureFlagEnabled = true;
|
||||
|
||||
//todo: remove this logic once feature flag gating is removed
|
||||
@ -85,10 +52,6 @@ export const CurrentWorkspaceMemberFavoritesFolders = () => {
|
||||
return <FavoritesSkeletonLoader />;
|
||||
}
|
||||
|
||||
const orphanFavorites = favorites.filter(
|
||||
(favorite) => !favorite.favoriteFolderId,
|
||||
);
|
||||
|
||||
if (!shouldDisplayFavorites) {
|
||||
return null;
|
||||
}
|
||||
@ -112,52 +75,14 @@ export const CurrentWorkspaceMemberFavoritesFolders = () => {
|
||||
</NavigationDrawerAnimatedCollapseWrapper>
|
||||
|
||||
{isNavigationSectionOpen && (
|
||||
<>
|
||||
<FavoritesDragProvider>
|
||||
{isFavoriteFolderEnabled && (
|
||||
<FavoriteFolders
|
||||
isNavigationSectionOpen={isNavigationSectionOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{orphanFavorites.length > 0 && (
|
||||
<DraggableList
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
draggableItems={orphanFavorites.map((favorite, index) => (
|
||||
<DraggableItem
|
||||
key={favorite.id}
|
||||
draggableId={favorite.id}
|
||||
index={index}
|
||||
isInsideScrollableContainer={true}
|
||||
itemComponent={
|
||||
<StyledOrphanFavoritesContainer>
|
||||
<NavigationDrawerItem
|
||||
key={favorite.id}
|
||||
className="navigation-drawer-item"
|
||||
label={favorite.labelIdentifier}
|
||||
Icon={() => <FavoriteIcon favorite={favorite} />}
|
||||
active={isLocationMatchingFavorite(
|
||||
currentPath,
|
||||
currentViewPath,
|
||||
favorite,
|
||||
)}
|
||||
to={favorite.link}
|
||||
rightOptions={
|
||||
<LightIconButton
|
||||
Icon={IconHeartOff}
|
||||
onClick={() => deleteFavorite(favorite.id)}
|
||||
accent="tertiary"
|
||||
/>
|
||||
}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
</StyledOrphanFavoritesContainer>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<CurrentWorkspaceMemberOrphanFavorites />
|
||||
</FavoritesDragProvider>
|
||||
)}
|
||||
</NavigationDrawerSection>
|
||||
);
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
import { FavoriteIcon } from '@/favorites/components/FavoriteIcon';
|
||||
import { FavoritesDroppable } from '@/favorites/components/FavoritesDroppable';
|
||||
import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext';
|
||||
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
|
||||
import { useFavorites } from '@/favorites/hooks/useFavorites';
|
||||
import { isLocationMatchingFavorite } from '@/favorites/utils/isLocationMatchingFavorite';
|
||||
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
|
||||
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
|
||||
import styled from '@emotion/styled';
|
||||
import { LightIconButton } from '@ui/input/button/components/LightIconButton';
|
||||
import { useContext } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { IconHeartOff } from 'twenty-ui';
|
||||
|
||||
const StyledEmptyContainer = styled.div`
|
||||
height: ${({ theme }) => theme.spacing(2.5)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const CurrentWorkspaceMemberOrphanFavorites = () => {
|
||||
const { sortedFavorites: favorites } = useFavorites();
|
||||
const { deleteFavorite } = useDeleteFavorite();
|
||||
const currentPath = useLocation().pathname;
|
||||
const currentViewPath = useLocation().pathname + useLocation().search;
|
||||
const { isDragging } = useContext(FavoritesDragContext);
|
||||
|
||||
const orphanFavorites = favorites.filter(
|
||||
(favorite) => !favorite.favoriteFolderId,
|
||||
);
|
||||
|
||||
return (
|
||||
<FavoritesDroppable droppableId="orphan-favorites">
|
||||
{orphanFavorites.length > 0 ? (
|
||||
orphanFavorites.map((favorite, index) => (
|
||||
<DraggableItem
|
||||
key={favorite.id}
|
||||
draggableId={favorite.id}
|
||||
index={index}
|
||||
isInsideScrollableContainer={true}
|
||||
itemComponent={
|
||||
<NavigationDrawerItem
|
||||
label={favorite.labelIdentifier}
|
||||
Icon={() => <FavoriteIcon favorite={favorite} />}
|
||||
active={isLocationMatchingFavorite(
|
||||
currentPath,
|
||||
currentViewPath,
|
||||
favorite,
|
||||
)}
|
||||
to={favorite.link}
|
||||
rightOptions={
|
||||
<LightIconButton
|
||||
Icon={IconHeartOff}
|
||||
onClick={() => deleteFavorite(favorite.id)}
|
||||
accent="tertiary"
|
||||
/>
|
||||
}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<StyledEmptyContainer />
|
||||
)}
|
||||
</FavoritesDroppable>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
import { FavoritesDragContext } from '@/favorites/contexts/FavoritesDragContext';
|
||||
import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop';
|
||||
import {
|
||||
DragDropContext,
|
||||
DragStart,
|
||||
DropResult,
|
||||
ResponderProvided,
|
||||
} from '@hello-pangea/dnd';
|
||||
import { ReactNode, useState } from 'react';
|
||||
|
||||
type FavoritesDragProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const FavoritesDragProvider = ({
|
||||
children,
|
||||
}: FavoritesDragProviderProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const { handleFavoriteDragAndDrop } = useHandleFavoriteDragAndDrop();
|
||||
|
||||
const handleDragStart = (_: DragStart) => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||
setIsDragging(false);
|
||||
handleFavoriteDragAndDrop(result, provided);
|
||||
};
|
||||
|
||||
return (
|
||||
<FavoritesDragContext.Provider value={{ isDragging }}>
|
||||
<DragDropContext onDragEnd={handleDragEnd} onDragStart={handleDragStart}>
|
||||
{children}
|
||||
</DragDropContext>
|
||||
</FavoritesDragContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,70 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Droppable } from '@hello-pangea/dnd';
|
||||
|
||||
type FavoritesDroppableProps = {
|
||||
droppableId: string;
|
||||
children: React.ReactNode;
|
||||
isDragIndicatorVisible?: boolean;
|
||||
showDropLine?: boolean;
|
||||
};
|
||||
|
||||
const StyledDroppableWrapper = styled.div<{
|
||||
isDraggingOver: boolean;
|
||||
isDragIndicatorVisible: boolean;
|
||||
showDropLine: boolean;
|
||||
}>`
|
||||
position: relative;
|
||||
transition: all 150ms ease-in-out;
|
||||
width: 100%;
|
||||
|
||||
${({ isDraggingOver, isDragIndicatorVisible, showDropLine, theme }) =>
|
||||
isDraggingOver &&
|
||||
isDragIndicatorVisible &&
|
||||
`
|
||||
background-color: ${theme.background.transparent.blue};
|
||||
|
||||
${
|
||||
showDropLine &&
|
||||
`
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: ${theme.color.blue};
|
||||
border-radius: ${theme.border.radius.sm} ${theme.border.radius.sm} 0 0;
|
||||
}
|
||||
`
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const FavoritesDroppable = ({
|
||||
droppableId,
|
||||
children,
|
||||
isDragIndicatorVisible = true,
|
||||
showDropLine = true,
|
||||
}: FavoritesDroppableProps) => {
|
||||
return (
|
||||
<Droppable droppableId={droppableId}>
|
||||
{(provided, snapshot) => (
|
||||
<StyledDroppableWrapper
|
||||
isDraggingOver={snapshot.isDraggingOver}
|
||||
isDragIndicatorVisible={isDragIndicatorVisible}
|
||||
showDropLine={showDropLine}
|
||||
>
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{children}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
</StyledDroppableWrapper>
|
||||
)}
|
||||
</Droppable>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,5 @@
|
||||
export const FAVORITE_DROPPABLE_IDS = {
|
||||
ORPHAN_FAVORITES: 'orphan-favorites',
|
||||
FOLDER_PREFIX: 'folder-',
|
||||
FOLDER_HEADER_PREFIX: 'folder-header-',
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
type FavoritesDragContextType = {
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
export const FavoritesDragContext = createContext<FavoritesDragContextType>({
|
||||
isDragging: false,
|
||||
});
|
||||
@ -25,7 +25,7 @@ export const initialFavorites = [
|
||||
person: { id: '1', name: 'John Doe' },
|
||||
company: { id: '2', name: 'ABC Corp' },
|
||||
workspaceMemberId: '1',
|
||||
favoriteFolderId: undefined,
|
||||
favoriteFolderId: '1',
|
||||
},
|
||||
{
|
||||
__typename: 'Favorite',
|
||||
@ -40,7 +40,7 @@ export const initialFavorites = [
|
||||
person: { id: '3', name: 'Jane Doe' },
|
||||
company: { id: '4', name: 'Company Test' },
|
||||
workspaceMemberId: '1',
|
||||
favoriteFolderId: undefined,
|
||||
favoriteFolderId: '1',
|
||||
|
||||
},
|
||||
{
|
||||
@ -54,7 +54,7 @@ export const initialFavorites = [
|
||||
link: 'example.com',
|
||||
recordId: '1',
|
||||
workspaceMemberId: '1',
|
||||
favoriteFolderId: undefined,
|
||||
favoriteFolderId: '1',
|
||||
},
|
||||
];
|
||||
|
||||
@ -69,7 +69,7 @@ export const sortedFavorites = [
|
||||
link: '/object/person/1',
|
||||
objectNameSingular: 'person',
|
||||
workspaceMemberId: '1',
|
||||
favoriteFolderId: undefined,
|
||||
favoriteFolderId: '1',
|
||||
__typename: 'Favorite',
|
||||
},
|
||||
{
|
||||
@ -82,7 +82,7 @@ export const sortedFavorites = [
|
||||
link: '/object/person/3',
|
||||
objectNameSingular: 'person',
|
||||
workspaceMemberId: '1',
|
||||
favoriteFolderId: undefined,
|
||||
favoriteFolderId: '1',
|
||||
__typename: 'Favorite',
|
||||
},
|
||||
{
|
||||
@ -94,12 +94,298 @@ export const sortedFavorites = [
|
||||
link: 'example.com',
|
||||
recordId: '1',
|
||||
avatarType: 'squared',
|
||||
favoriteFolderId: undefined,
|
||||
favoriteFolderId: '1',
|
||||
workspaceMemberId: '1',
|
||||
__typename: 'Favorite',
|
||||
},
|
||||
];
|
||||
|
||||
const UPDATE_ONE_FAVORITE_MUTATION = gql`
|
||||
mutation UpdateOneFavorite(
|
||||
$idToUpdate: ID!
|
||||
$input: FavoriteUpdateInput!
|
||||
) {
|
||||
updateFavorite(id: $idToUpdate, data: $input) {
|
||||
__typename
|
||||
company {
|
||||
__typename
|
||||
accountOwnerId
|
||||
address {
|
||||
addressStreet1
|
||||
addressStreet2
|
||||
addressCity
|
||||
addressState
|
||||
addressCountry
|
||||
addressPostcode
|
||||
addressLat
|
||||
addressLng
|
||||
}
|
||||
annualRecurringRevenue {
|
||||
amountMicros
|
||||
currencyCode
|
||||
}
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
domainName {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
employees
|
||||
id
|
||||
idealCustomerProfile
|
||||
introVideo {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
linkedinLink {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
name
|
||||
position
|
||||
tagline
|
||||
updatedAt
|
||||
visaSponsorship
|
||||
workPolicy
|
||||
xLink {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
}
|
||||
companyId
|
||||
createdAt
|
||||
deletedAt
|
||||
favoriteFolder {
|
||||
__typename
|
||||
createdAt
|
||||
deletedAt
|
||||
id
|
||||
name
|
||||
position
|
||||
updatedAt
|
||||
}
|
||||
favoriteFolderId
|
||||
id
|
||||
note {
|
||||
__typename
|
||||
body
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
id
|
||||
position
|
||||
title
|
||||
updatedAt
|
||||
}
|
||||
noteId
|
||||
opportunity {
|
||||
__typename
|
||||
amount {
|
||||
amountMicros
|
||||
currencyCode
|
||||
}
|
||||
closeDate
|
||||
companyId
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
id
|
||||
name
|
||||
pointOfContactId
|
||||
position
|
||||
stage
|
||||
updatedAt
|
||||
}
|
||||
opportunityId
|
||||
person {
|
||||
__typename
|
||||
avatarUrl
|
||||
city
|
||||
companyId
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
emails {
|
||||
primaryEmail
|
||||
additionalEmails
|
||||
}
|
||||
id
|
||||
intro
|
||||
jobTitle
|
||||
linkedinLink {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
name {
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
performanceRating
|
||||
phones {
|
||||
primaryPhoneNumber
|
||||
primaryPhoneCountryCode
|
||||
additionalPhones
|
||||
}
|
||||
position
|
||||
updatedAt
|
||||
whatsapp {
|
||||
primaryPhoneNumber
|
||||
primaryPhoneCountryCode
|
||||
additionalPhones
|
||||
}
|
||||
workPreference
|
||||
xLink {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
}
|
||||
personId
|
||||
position
|
||||
rocket {
|
||||
__typename
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
id
|
||||
name
|
||||
position
|
||||
updatedAt
|
||||
}
|
||||
rocketId
|
||||
task {
|
||||
__typename
|
||||
assigneeId
|
||||
body
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
dueAt
|
||||
id
|
||||
position
|
||||
status
|
||||
title
|
||||
updatedAt
|
||||
}
|
||||
taskId
|
||||
updatedAt
|
||||
view {
|
||||
__typename
|
||||
createdAt
|
||||
deletedAt
|
||||
icon
|
||||
id
|
||||
isCompact
|
||||
kanbanFieldMetadataId
|
||||
key
|
||||
name
|
||||
objectMetadataId
|
||||
position
|
||||
type
|
||||
updatedAt
|
||||
}
|
||||
viewId
|
||||
workflow {
|
||||
__typename
|
||||
createdAt
|
||||
deletedAt
|
||||
id
|
||||
lastPublishedVersionId
|
||||
name
|
||||
position
|
||||
statuses
|
||||
updatedAt
|
||||
}
|
||||
workflowId
|
||||
workflowRun {
|
||||
__typename
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
endedAt
|
||||
id
|
||||
name
|
||||
output
|
||||
position
|
||||
startedAt
|
||||
status
|
||||
updatedAt
|
||||
workflowId
|
||||
workflowVersionId
|
||||
}
|
||||
workflowRunId
|
||||
workflowVersion {
|
||||
__typename
|
||||
createdAt
|
||||
deletedAt
|
||||
id
|
||||
name
|
||||
position
|
||||
status
|
||||
steps
|
||||
trigger
|
||||
updatedAt
|
||||
workflowId
|
||||
}
|
||||
workflowVersionId
|
||||
workspaceMember {
|
||||
__typename
|
||||
avatarUrl
|
||||
colorScheme
|
||||
createdAt
|
||||
dateFormat
|
||||
deletedAt
|
||||
id
|
||||
locale
|
||||
name {
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
timeFormat
|
||||
timeZone
|
||||
updatedAt
|
||||
userEmail
|
||||
userId
|
||||
}
|
||||
workspaceMemberId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const mocks = [
|
||||
{
|
||||
request: {
|
||||
@ -388,7 +674,7 @@ export const mocks = [
|
||||
variables: {
|
||||
input: {
|
||||
personId: favoriteTargetObjectId,
|
||||
position: 3,
|
||||
position: 1,
|
||||
workspaceMemberId: '1',
|
||||
favoriteFolderId: undefined,
|
||||
id: mockId,
|
||||
@ -430,291 +716,7 @@ export const mocks = [
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: gql`
|
||||
mutation UpdateOneFavorite(
|
||||
$idToUpdate: ID!
|
||||
$input: FavoriteUpdateInput!
|
||||
) {
|
||||
updateFavorite(id: $idToUpdate, data: $input) {
|
||||
__typename
|
||||
company {
|
||||
__typename
|
||||
accountOwnerId
|
||||
address {
|
||||
addressStreet1
|
||||
addressStreet2
|
||||
addressCity
|
||||
addressState
|
||||
addressCountry
|
||||
addressPostcode
|
||||
addressLat
|
||||
addressLng
|
||||
}
|
||||
annualRecurringRevenue {
|
||||
amountMicros
|
||||
currencyCode
|
||||
}
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
domainName {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
employees
|
||||
id
|
||||
idealCustomerProfile
|
||||
introVideo {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
linkedinLink {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
name
|
||||
position
|
||||
tagline
|
||||
updatedAt
|
||||
visaSponsorship
|
||||
workPolicy
|
||||
xLink {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
}
|
||||
companyId
|
||||
createdAt
|
||||
deletedAt
|
||||
favoriteFolder {
|
||||
__typename
|
||||
createdAt
|
||||
deletedAt
|
||||
id
|
||||
name
|
||||
position
|
||||
updatedAt
|
||||
}
|
||||
favoriteFolderId
|
||||
id
|
||||
note {
|
||||
__typename
|
||||
body
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
id
|
||||
position
|
||||
title
|
||||
updatedAt
|
||||
}
|
||||
noteId
|
||||
opportunity {
|
||||
__typename
|
||||
amount {
|
||||
amountMicros
|
||||
currencyCode
|
||||
}
|
||||
closeDate
|
||||
companyId
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
id
|
||||
name
|
||||
pointOfContactId
|
||||
position
|
||||
stage
|
||||
updatedAt
|
||||
}
|
||||
opportunityId
|
||||
person {
|
||||
__typename
|
||||
avatarUrl
|
||||
city
|
||||
companyId
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
emails {
|
||||
primaryEmail
|
||||
additionalEmails
|
||||
}
|
||||
id
|
||||
intro
|
||||
jobTitle
|
||||
linkedinLink {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
name {
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
performanceRating
|
||||
phones {
|
||||
primaryPhoneNumber
|
||||
primaryPhoneCountryCode
|
||||
additionalPhones
|
||||
}
|
||||
position
|
||||
updatedAt
|
||||
whatsapp {
|
||||
primaryPhoneNumber
|
||||
primaryPhoneCountryCode
|
||||
additionalPhones
|
||||
}
|
||||
workPreference
|
||||
xLink {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
}
|
||||
}
|
||||
personId
|
||||
position
|
||||
rocket {
|
||||
__typename
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
id
|
||||
name
|
||||
position
|
||||
updatedAt
|
||||
}
|
||||
rocketId
|
||||
task {
|
||||
__typename
|
||||
assigneeId
|
||||
body
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
dueAt
|
||||
id
|
||||
position
|
||||
status
|
||||
title
|
||||
updatedAt
|
||||
}
|
||||
taskId
|
||||
updatedAt
|
||||
view {
|
||||
__typename
|
||||
createdAt
|
||||
deletedAt
|
||||
icon
|
||||
id
|
||||
isCompact
|
||||
kanbanFieldMetadataId
|
||||
key
|
||||
name
|
||||
objectMetadataId
|
||||
position
|
||||
type
|
||||
updatedAt
|
||||
}
|
||||
viewId
|
||||
workflow {
|
||||
__typename
|
||||
createdAt
|
||||
deletedAt
|
||||
id
|
||||
lastPublishedVersionId
|
||||
name
|
||||
position
|
||||
statuses
|
||||
updatedAt
|
||||
}
|
||||
workflowId
|
||||
workflowRun {
|
||||
__typename
|
||||
createdAt
|
||||
createdBy {
|
||||
source
|
||||
workspaceMemberId
|
||||
name
|
||||
}
|
||||
deletedAt
|
||||
endedAt
|
||||
id
|
||||
name
|
||||
output
|
||||
position
|
||||
startedAt
|
||||
status
|
||||
updatedAt
|
||||
workflowId
|
||||
workflowVersionId
|
||||
}
|
||||
workflowRunId
|
||||
workflowVersion {
|
||||
__typename
|
||||
createdAt
|
||||
deletedAt
|
||||
id
|
||||
name
|
||||
position
|
||||
status
|
||||
steps
|
||||
trigger
|
||||
updatedAt
|
||||
workflowId
|
||||
}
|
||||
workflowVersionId
|
||||
workspaceMember {
|
||||
__typename
|
||||
avatarUrl
|
||||
colorScheme
|
||||
createdAt
|
||||
dateFormat
|
||||
deletedAt
|
||||
id
|
||||
locale
|
||||
name {
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
timeFormat
|
||||
timeZone
|
||||
updatedAt
|
||||
userEmail
|
||||
userId
|
||||
}
|
||||
workspaceMemberId
|
||||
}
|
||||
}
|
||||
`,
|
||||
query: UPDATE_ONE_FAVORITE_MUTATION,
|
||||
variables: {
|
||||
idToUpdate: '1',
|
||||
input: {
|
||||
@ -726,12 +728,60 @@ export const mocks = [
|
||||
data: {
|
||||
updateFavorite: {
|
||||
__typename: 'Favorite',
|
||||
id: favoriteId,
|
||||
id: favoriteId,
|
||||
position: 2,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
|
||||
// Mock for folder move
|
||||
{
|
||||
request: {
|
||||
query: UPDATE_ONE_FAVORITE_MUTATION,
|
||||
variables: {
|
||||
idToUpdate: '1',
|
||||
input: {
|
||||
position: 0,
|
||||
favoriteFolderId: '2',
|
||||
},
|
||||
},
|
||||
},
|
||||
result: jest.fn(() => ({
|
||||
data: {
|
||||
updateFavorite: {
|
||||
__typename: 'Favorite',
|
||||
id: favoriteId,
|
||||
position: 0,
|
||||
favoriteFolderId: '2',
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
|
||||
// Mock for orphan favorites
|
||||
{
|
||||
request: {
|
||||
query: UPDATE_ONE_FAVORITE_MUTATION,
|
||||
variables: {
|
||||
idToUpdate: '1',
|
||||
input: {
|
||||
position: 0,
|
||||
favoriteFolderId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
result: jest.fn(() => ({
|
||||
data: {
|
||||
updateFavorite: {
|
||||
__typename: 'Favorite',
|
||||
id: favoriteId,
|
||||
position: 0,
|
||||
favoriteFolderId: null,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
export const mockWorkspaceMember = {
|
||||
|
||||
@ -0,0 +1,174 @@
|
||||
import { DropResult, ResponderProvided } from '@hello-pangea/dnd';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
|
||||
import { useHandleFavoriteDragAndDrop } from '@/favorites/hooks/useHandleFavoriteDragAndDrop';
|
||||
import { createFolderDroppableId } from '@/favorites/utils/createFolderDroppableId';
|
||||
import { createFolderHeaderDroppableId } from '@/favorites/utils/createFolderHeaderDroppableId';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
||||
|
||||
import {
|
||||
initialFavorites,
|
||||
mockWorkspaceMember,
|
||||
mocks,
|
||||
} from '../__mocks__/useFavorites';
|
||||
|
||||
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
|
||||
useFindManyRecords: () => ({ records: initialFavorites }),
|
||||
}));
|
||||
|
||||
const Wrapper = getJestMetadataAndApolloMocksWrapper({
|
||||
apolloMocks: mocks,
|
||||
});
|
||||
|
||||
describe('useHandleFavoriteDragAndDrop', () => {
|
||||
const mockResponderProvided: ResponderProvided = {
|
||||
announce: jest.fn(),
|
||||
};
|
||||
|
||||
const setupHook = () => {
|
||||
return renderHook(
|
||||
() => {
|
||||
const setCurrentWorkspaceMember = useSetRecoilState(
|
||||
currentWorkspaceMemberState,
|
||||
);
|
||||
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
|
||||
|
||||
setCurrentWorkspaceMember(mockWorkspaceMember);
|
||||
setMetadataItems(generatedMockObjectMetadataItems);
|
||||
|
||||
return {
|
||||
hook: useHandleFavoriteDragAndDrop(),
|
||||
};
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
};
|
||||
|
||||
it('should not update when destination is null', () => {
|
||||
const { result } = setupHook();
|
||||
|
||||
act(() => {
|
||||
result.current.hook.handleFavoriteDragAndDrop(
|
||||
{
|
||||
source: { index: 0, droppableId: createFolderDroppableId('1') },
|
||||
destination: null,
|
||||
draggableId: '1',
|
||||
} as DropResult,
|
||||
mockResponderProvided,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks[2].result).not.toHaveBeenCalled();
|
||||
expect(mocks[3].result).not.toHaveBeenCalled();
|
||||
expect(mocks[4].result).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update when destination is same as source', () => {
|
||||
const { result } = setupHook();
|
||||
const folderOneId = createFolderDroppableId('1');
|
||||
|
||||
act(() => {
|
||||
result.current.hook.handleFavoriteDragAndDrop(
|
||||
{
|
||||
source: { index: 0, droppableId: folderOneId },
|
||||
destination: { index: 0, droppableId: folderOneId },
|
||||
draggableId: '1',
|
||||
} as DropResult,
|
||||
mockResponderProvided,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks[2].result).not.toHaveBeenCalled();
|
||||
expect(mocks[3].result).not.toHaveBeenCalled();
|
||||
expect(mocks[4].result).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reorder within same folder', async () => {
|
||||
const { result } = setupHook();
|
||||
const folderOneId = createFolderDroppableId('1');
|
||||
|
||||
act(() => {
|
||||
result.current.hook.handleFavoriteDragAndDrop(
|
||||
{
|
||||
source: { index: 0, droppableId: folderOneId },
|
||||
destination: { index: 2, droppableId: folderOneId },
|
||||
draggableId: '1',
|
||||
} as DropResult,
|
||||
mockResponderProvided,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks[2].result).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should move to another folder', async () => {
|
||||
const { result } = setupHook();
|
||||
|
||||
act(() => {
|
||||
result.current.hook.handleFavoriteDragAndDrop(
|
||||
{
|
||||
source: { index: 0, droppableId: createFolderDroppableId('1') },
|
||||
destination: { index: 0, droppableId: createFolderDroppableId('2') },
|
||||
draggableId: '1',
|
||||
} as DropResult,
|
||||
mockResponderProvided,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks[3].result).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should move to orphan favorites', async () => {
|
||||
const { result } = setupHook();
|
||||
|
||||
act(() => {
|
||||
result.current.hook.handleFavoriteDragAndDrop(
|
||||
{
|
||||
source: { index: 0, droppableId: createFolderDroppableId('1') },
|
||||
destination: {
|
||||
index: 0,
|
||||
droppableId: FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
|
||||
},
|
||||
draggableId: '1',
|
||||
} as DropResult,
|
||||
mockResponderProvided,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks[4].result).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle dropping into folder header', async () => {
|
||||
const { result } = setupHook();
|
||||
|
||||
act(() => {
|
||||
result.current.hook.handleFavoriteDragAndDrop(
|
||||
{
|
||||
source: { index: 0, droppableId: createFolderDroppableId('1') },
|
||||
destination: {
|
||||
index: 0,
|
||||
droppableId: createFolderHeaderDroppableId('2'),
|
||||
},
|
||||
draggableId: '1',
|
||||
} as DropResult,
|
||||
mockResponderProvided,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks[3].result).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,67 +0,0 @@
|
||||
import { DropResult, ResponderProvided } from '@hello-pangea/dnd';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { useReorderFavorite } from '@/favorites/hooks/useReorderFavorite';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
||||
import {
|
||||
initialFavorites,
|
||||
mockWorkspaceMember,
|
||||
mocks,
|
||||
} from '../__mocks__/useFavorites';
|
||||
|
||||
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
|
||||
useFindManyRecords: () => ({ records: initialFavorites }),
|
||||
}));
|
||||
|
||||
const Wrapper = getJestMetadataAndApolloMocksWrapper({
|
||||
apolloMocks: mocks,
|
||||
});
|
||||
|
||||
describe('useReorderFavorite', () => {
|
||||
it('should handle reordering favorites successfully', async () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const setCurrentWorkspaceMember = useSetRecoilState(
|
||||
currentWorkspaceMemberState,
|
||||
);
|
||||
setCurrentWorkspaceMember(mockWorkspaceMember);
|
||||
|
||||
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
|
||||
setMetadataItems(generatedMockObjectMetadataItems);
|
||||
|
||||
return useReorderFavorite();
|
||||
},
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
const dragAndDropResult: DropResult = {
|
||||
source: { index: 0, droppableId: 'droppableId' },
|
||||
destination: { index: 2, droppableId: 'droppableId' },
|
||||
combine: null,
|
||||
mode: 'FLUID',
|
||||
draggableId: '1',
|
||||
type: 'type',
|
||||
reason: 'DROP',
|
||||
};
|
||||
|
||||
const responderProvided: ResponderProvided = {
|
||||
announce: () => {},
|
||||
};
|
||||
|
||||
result.current.handleReorderFavorite(
|
||||
dragAndDropResult,
|
||||
responderProvided,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks[2].result).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,119 @@
|
||||
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
|
||||
import { useSortedFavorites } from '@/favorites/hooks/useSortedFavorites';
|
||||
import { activeFavoriteFolderIdState } from '@/favorites/states/activeFavoriteFolderIdState';
|
||||
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
|
||||
import { validateAndExtractFolderId } from '@/favorites/utils/validateAndExtractFolderId';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { OnDragEndResponder } from '@hello-pangea/dnd';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
|
||||
|
||||
export const useHandleFavoriteDragAndDrop = () => {
|
||||
const { favorites } = usePrefetchedFavoritesData();
|
||||
const { favoritesSorted } = useSortedFavorites();
|
||||
const { updateOneRecord: updateOneFavorite } = useUpdateOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.Favorite,
|
||||
});
|
||||
const setActiveFavoriteFolderId = useSetRecoilState(
|
||||
activeFavoriteFolderIdState,
|
||||
);
|
||||
|
||||
const handleFavoriteDragAndDrop: OnDragEndResponder = (result) => {
|
||||
const { destination, source, draggableId } = result;
|
||||
|
||||
if (!destination) return;
|
||||
|
||||
if (
|
||||
destination.droppableId === source.droppableId &&
|
||||
destination.index === source.index
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draggedFavorite = favorites.find((f) => f.id === draggableId);
|
||||
if (!draggedFavorite) return;
|
||||
|
||||
const destinationFolderId = validateAndExtractFolderId(
|
||||
destination.droppableId,
|
||||
);
|
||||
const sourceFolderId = validateAndExtractFolderId(source.droppableId);
|
||||
|
||||
if (
|
||||
destination.droppableId.startsWith(
|
||||
FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
|
||||
)
|
||||
) {
|
||||
if (destinationFolderId === null)
|
||||
throw new Error('Invalid folder header ID');
|
||||
|
||||
const folderFavorites = favoritesSorted.filter(
|
||||
(favorite) => favorite.favoriteFolderId === destinationFolderId,
|
||||
);
|
||||
|
||||
const newPosition =
|
||||
folderFavorites.length === 0
|
||||
? 0
|
||||
: folderFavorites[folderFavorites.length - 1].position + 1;
|
||||
|
||||
updateOneFavorite({
|
||||
idToUpdate: draggableId,
|
||||
updateOneRecordInput: {
|
||||
favoriteFolderId: destinationFolderId,
|
||||
position: newPosition,
|
||||
},
|
||||
});
|
||||
|
||||
setActiveFavoriteFolderId(destinationFolderId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (destination.droppableId !== source.droppableId) {
|
||||
const destinationFavorites = favoritesSorted.filter(
|
||||
(favorite) => favorite.favoriteFolderId === destinationFolderId,
|
||||
);
|
||||
|
||||
let newPosition;
|
||||
if (destinationFavorites.length === 0) {
|
||||
newPosition = 0;
|
||||
} else if (destination.index === 0) {
|
||||
newPosition = destinationFavorites[0].position / 2;
|
||||
} else if (destination.index >= destinationFavorites.length) {
|
||||
newPosition =
|
||||
destinationFavorites[destinationFavorites.length - 1].position + 1;
|
||||
} else {
|
||||
newPosition = calculateNewPosition({
|
||||
destinationIndex: destination.index,
|
||||
sourceIndex: -1,
|
||||
items: destinationFavorites,
|
||||
});
|
||||
}
|
||||
|
||||
updateOneFavorite({
|
||||
idToUpdate: draggableId,
|
||||
updateOneRecordInput: {
|
||||
favoriteFolderId: destinationFolderId,
|
||||
position: newPosition,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const favoritesInSameList = favoritesSorted.filter(
|
||||
(favorite) => favorite.favoriteFolderId === sourceFolderId,
|
||||
);
|
||||
|
||||
const newPosition = calculateNewPosition({
|
||||
destinationIndex: destination.index,
|
||||
sourceIndex: source.index,
|
||||
items: favoritesInSameList,
|
||||
});
|
||||
|
||||
updateOneFavorite({
|
||||
idToUpdate: draggableId,
|
||||
updateOneRecordInput: { position: newPosition },
|
||||
});
|
||||
};
|
||||
|
||||
return { handleFavoriteDragAndDrop };
|
||||
};
|
||||
@ -1,41 +0,0 @@
|
||||
import { useSortedFavorites } from '@/favorites/hooks/useSortedFavorites';
|
||||
import { calculateNewPosition } from '@/favorites/utils/calculateNewPosition';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { OnDragEndResponder } from '@hello-pangea/dnd';
|
||||
import { usePrefetchedFavoritesData } from './usePrefetchedFavoritesData';
|
||||
|
||||
export const useReorderFavorite = () => {
|
||||
const { favorites } = usePrefetchedFavoritesData();
|
||||
const { favoritesSorted } = useSortedFavorites();
|
||||
const { updateOneRecord: updateOneFavorite } = useUpdateOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.Favorite,
|
||||
});
|
||||
|
||||
const handleReorderFavorite: OnDragEndResponder = (result) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const draggedFavoriteId = result.draggableId;
|
||||
const draggedFavorite = favorites.find((f) => f.id === draggedFavoriteId);
|
||||
|
||||
if (!draggedFavorite) return;
|
||||
|
||||
const inSameFolderFavorites = favoritesSorted.filter(
|
||||
(fav) => fav.favoriteFolderId === draggedFavorite.favoriteFolderId,
|
||||
);
|
||||
if (!inSameFolderFavorites.length) return;
|
||||
|
||||
const newPosition = calculateNewPosition({
|
||||
destinationIndex: result.destination.index,
|
||||
sourceIndex: result.source.index,
|
||||
items: inSameFolderFavorites,
|
||||
});
|
||||
|
||||
updateOneFavorite({
|
||||
idToUpdate: draggedFavoriteId,
|
||||
updateOneRecordInput: { position: newPosition },
|
||||
});
|
||||
};
|
||||
|
||||
return { handleReorderFavorite };
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
|
||||
|
||||
export type FavoriteDroppableId =
|
||||
| typeof FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES
|
||||
| `${typeof FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}${string}`
|
||||
| `${typeof FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}${string}`;
|
||||
@ -0,0 +1,17 @@
|
||||
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
|
||||
import { createFolderDroppableId } from '../createFolderDroppableId';
|
||||
|
||||
describe('createFolderDroppableId', () => {
|
||||
it('should create a valid folder droppable id', () => {
|
||||
const folderId = '123-456';
|
||||
const result = createFolderDroppableId(folderId);
|
||||
|
||||
expect(result).toBe(`${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}${folderId}`);
|
||||
});
|
||||
|
||||
it('should work with empty string', () => {
|
||||
const result = createFolderDroppableId('');
|
||||
|
||||
expect(result).toBe(FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,19 @@
|
||||
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
|
||||
import { createFolderHeaderDroppableId } from '../createFolderHeaderDroppableId';
|
||||
|
||||
describe('createFolderHeaderDroppableId', () => {
|
||||
it('should create a valid folder header droppable id', () => {
|
||||
const folderId = '123-456';
|
||||
const result = createFolderHeaderDroppableId(folderId);
|
||||
|
||||
expect(result).toBe(
|
||||
`${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}${folderId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should work with empty string', () => {
|
||||
const result = createFolderHeaderDroppableId('');
|
||||
|
||||
expect(result).toBe(FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,47 @@
|
||||
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
|
||||
import { validateAndExtractFolderId } from '../validateAndExtractFolderId';
|
||||
|
||||
describe('validateAndExtractFolderId', () => {
|
||||
it('should return null for orphan favorites', () => {
|
||||
const result = validateAndExtractFolderId(
|
||||
FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should extract folder id from folder droppable id', () => {
|
||||
const folderId = '123-456';
|
||||
const droppableId = `${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}${folderId}`;
|
||||
|
||||
const result = validateAndExtractFolderId(droppableId);
|
||||
expect(result).toBe(folderId);
|
||||
});
|
||||
|
||||
it('should extract folder id from folder header droppable id', () => {
|
||||
const folderId = '123-456';
|
||||
const droppableId = `${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}${folderId}`;
|
||||
|
||||
const result = validateAndExtractFolderId(droppableId);
|
||||
expect(result).toBe(folderId);
|
||||
});
|
||||
|
||||
it('should throw error for invalid droppable id format', () => {
|
||||
expect(() => {
|
||||
validateAndExtractFolderId('invalid-id');
|
||||
}).toThrow('Invalid droppable ID format: invalid-id');
|
||||
});
|
||||
|
||||
it('should throw error for empty folder id in folder format', () => {
|
||||
expect(() => {
|
||||
validateAndExtractFolderId(FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX);
|
||||
}).toThrow(`Invalid folder ID: ${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}`);
|
||||
});
|
||||
|
||||
it('should throw error for empty folder id in folder header format', () => {
|
||||
expect(() => {
|
||||
validateAndExtractFolderId(FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX);
|
||||
}).toThrow(
|
||||
`Invalid folder header ID: ${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
|
||||
import { FavoriteDroppableId } from '@/favorites/types/FavoriteDroppableId';
|
||||
|
||||
export const createFolderDroppableId = (
|
||||
folderId: string,
|
||||
): FavoriteDroppableId => `${FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX}${folderId}`;
|
||||
@ -0,0 +1,7 @@
|
||||
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
|
||||
import { FavoriteDroppableId } from '@/favorites/types/FavoriteDroppableId';
|
||||
|
||||
export const createFolderHeaderDroppableId = (
|
||||
folderId: string,
|
||||
): FavoriteDroppableId =>
|
||||
`${FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX}${folderId}`;
|
||||
@ -0,0 +1,29 @@
|
||||
import { FAVORITE_DROPPABLE_IDS } from '@/favorites/constants/FavoriteDroppableIds';
|
||||
|
||||
export const validateAndExtractFolderId = (
|
||||
droppableId: string,
|
||||
): string | null => {
|
||||
if (droppableId === FAVORITE_DROPPABLE_IDS.ORPHAN_FAVORITES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (droppableId.startsWith(FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX)) {
|
||||
const folderId = droppableId.replace(
|
||||
FAVORITE_DROPPABLE_IDS.FOLDER_HEADER_PREFIX,
|
||||
'',
|
||||
);
|
||||
if (!folderId) throw new Error(`Invalid folder header ID: ${droppableId}`);
|
||||
return folderId;
|
||||
}
|
||||
|
||||
if (droppableId.startsWith(FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX)) {
|
||||
const folderId = droppableId.replace(
|
||||
FAVORITE_DROPPABLE_IDS.FOLDER_PREFIX,
|
||||
'',
|
||||
);
|
||||
if (!folderId) throw new Error(`Invalid folder ID: ${droppableId}`);
|
||||
return folderId;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid droppable ID format: ${droppableId}`);
|
||||
};
|
||||
Reference in New Issue
Block a user