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:
nitin
2024-12-17 17:16:58 +05:30
committed by GitHub
parent 4fe3250e81
commit 582530ef1e
19 changed files with 992 additions and 516 deletions

View File

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

View File

@ -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>
);

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export const FAVORITE_DROPPABLE_IDS = {
ORPHAN_FAVORITES: 'orphan-favorites',
FOLDER_PREFIX: 'folder-',
FOLDER_HEADER_PREFIX: 'folder-header-',
};

View File

@ -0,0 +1,9 @@
import { createContext } from 'react';
type FavoritesDragContextType = {
isDragging: boolean;
};
export const FavoritesDragContext = createContext<FavoritesDragContextType>({
isDragging: false,
});

View File

@ -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 = {

View File

@ -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();
});
});
});

View File

@ -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();
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}`);
};